sub2api/backend/internal/service/gateway_attribution.go
win a3f2d4577e chore: remove LS pool implementation
Removing all LS (Language Server Pool) related code:
- backend/cmd/lsworker/
- backend/internal/pkg/lspool/
- backend/internal/service/lspool_bootstrap_service.*
- deploy/ls-bin/
- deploy/lsworker.Dockerfile
- deploy/lsworker-entrypoint.sh

Keeping:
- Claude custom fingerprint (immutable)
- Antigravity OAuth and telemetry improvements
- TLS fingerprint SOCKS5 Docker DNS fix
- Gemini OAuth security improvements

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-04-08 23:43:05 +08:00

254 lines
8.5 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 (
"crypto/sha256"
"encoding/hex"
"fmt"
"regexp"
"sync"
"time"
"github.com/google/uuid"
"github.com/tidwall/gjson"
"github.com/tidwall/sjson"
"github.com/Wei-Shaw/sub2api/internal/pkg/logger"
)
// Attribution block constants matching real Claude Code 2.1.89.
// Source: src/constants/system.ts + src/utils/fingerprint.ts
const (
// fingerprintSalt must match the hardcoded salt in the real CLI.
// Source: extracted/src/utils/fingerprint.ts:8
fingerprintSalt = "59cf53e54c78"
)
// computeAttributionFingerprint computes a 3-character hex fingerprint
// matching the algorithm in the real Claude Code CLI.
//
// Algorithm: SHA256(SALT + msg[4] + msg[7] + msg[20] + version)[:3]
// Source: extracted/src/utils/fingerprint.ts:50-63
func computeAttributionFingerprint(firstUserMessageText, cliVersion string) string {
indices := [3]int{4, 7, 20}
chars := make([]byte, 0, 3)
for _, i := range indices {
if i < len(firstUserMessageText) {
chars = append(chars, firstUserMessageText[i])
} else {
chars = append(chars, '0')
}
}
input := fmt.Sprintf("%s%s%s", fingerprintSalt, string(chars), cliVersion)
hash := sha256.Sum256([]byte(input))
return hex.EncodeToString(hash[:])[:3]
}
// extractFirstUserMessageText extracts text from the first user message in the body.
// Handles both string content and array content (text blocks).
func extractFirstUserMessageText(body []byte) string {
messages := gjson.GetBytes(body, "messages")
if !messages.Exists() || !messages.IsArray() {
return ""
}
var firstText string
messages.ForEach(func(_, msg gjson.Result) bool {
if msg.Get("role").String() != "user" {
return true // continue
}
content := msg.Get("content")
if content.Type == gjson.String {
firstText = content.String()
return false // break
}
if content.IsArray() {
content.ForEach(func(_, block gjson.Result) bool {
if block.Get("type").String() == "text" {
firstText = block.Get("text").String()
return false
}
return true
})
return false
}
return true
})
return firstText
}
// buildAttributionBlock builds the x-anthropic-billing-header attribution string
// that real Claude Code injects as the first system text block.
//
// Format: x-anthropic-billing-header: cc_version=<VERSION>.<fingerprint>; cc_entrypoint=cli; cch=00000;
// Source: extracted/src/constants/system.ts:73-95
func buildAttributionBlock(cliVersion, fingerprint string) string {
version := cliVersion + "." + fingerprint
// 2.1.89 起 cch=00000 出现在所有安装模式(含 npm 版),不再只限于原生二进制。
// 原生二进制由 Bun 的 Zig 层在运行时将 00000 替换为真实 attestation hash
// 普通安装版保持 00000 占位符不变。
return fmt.Sprintf("x-anthropic-billing-header: cc_version=%s; cc_entrypoint=cli; cch=00000;", version)
}
// injectAttributionBlock prepends the x-anthropic-billing-header attribution block
// as the very first system text block in the request body.
// This must come BEFORE the "You are Claude Code" block.
//
// The real CLI injects this as system[0] with cache_control: {type: "ephemeral"}.
func injectAttributionBlock(body []byte, cliVersion string) []byte {
// Compute fingerprint from the first user message
firstMsgText := extractFirstUserMessageText(body)
fingerprint := computeAttributionFingerprint(firstMsgText, cliVersion)
attribution := buildAttributionBlock(cliVersion, fingerprint)
// Build the attribution text block as JSON
attrBlock, err := marshalAnthropicSystemTextBlock(attribution, true)
if err != nil {
logger.LegacyPrintf("service.gateway", "Warning: failed to build attribution block: %v", err)
return body
}
systemResult := gjson.GetBytes(body, "system")
// Handle the different system formats
switch {
case !systemResult.Exists() || systemResult.Type == gjson.Null:
// No system field — inject just the attribution block
newBody, err := sjson.SetRawBytes(body, "system", buildJSONArrayRaw([][]byte{attrBlock}))
if err != nil {
return body
}
return newBody
case systemResult.Type == gjson.String:
// String system — convert to array: [attribution, original]
origBlock, err := marshalAnthropicSystemTextBlock(systemResult.String(), false)
if err != nil {
return body
}
newBody, setErr := sjson.SetRawBytes(body, "system", buildJSONArrayRaw([][]byte{attrBlock, origBlock}))
if setErr != nil {
return body
}
return newBody
case systemResult.IsArray():
// Array system — check if attribution already exists, prepend if not
var items [][]byte
alreadyHasAttribution := false
systemResult.ForEach(func(_, item gjson.Result) bool {
if item.Get("type").String() == "text" {
text := item.Get("text").String()
if len(text) > 30 && text[:30] == "x-anthropic-billing-header: cc" {
alreadyHasAttribution = true
}
}
return true
})
if alreadyHasAttribution {
return body
}
items = append(items, attrBlock)
systemResult.ForEach(func(_, item gjson.Result) bool {
items = append(items, []byte(item.Raw))
return true
})
newBody, setErr := sjson.SetRawBytes(body, "system", buildJSONArrayRaw(items))
if setErr != nil {
return body
}
return newBody
default:
return body
}
}
// cliSessionEntry holds a cached session UUID with an expiration time.
type cliSessionEntry struct {
id string
expiresAt time.Time
}
// cliSessionCache stores per-account session UUIDs that rotate on a TTL.
// Real CLI creates a new random UUID per process invocation; we approximate
// this by rotating every 30-60 minutes (jittered per account).
var (
cliSessionCache = make(map[int64]cliSessionEntry)
cliSessionCacheMu sync.Mutex
)
// sessionTTLBase is the base TTL for session ID rotation.
const sessionTTLBase = 30 * time.Minute
// generateSessionIDForAccount returns a per-account session UUID that rotates
// periodically. Each account gets a random TTL jitter (0-30 min on top of
// the 30 min base) so accounts don't all rotate simultaneously.
func generateSessionIDForAccount(instanceSalt string, accountID int64) string {
cliSessionCacheMu.Lock()
defer cliSessionCacheMu.Unlock()
now := time.Now()
if entry, ok := cliSessionCache[accountID]; ok && now.Before(entry.expiresAt) {
return entry.id
}
// Compute per-account jitter from a hash so the same account always gets
// the same jitter within a process (avoids re-rolling on every rotation).
jitterSeed := fmt.Sprintf("jitter:%s:%d", instanceSalt, accountID)
h := sha256.Sum256([]byte(jitterSeed))
jitterMinutes := int(h[0]) % 31 // 0-30 minutes
ttl := sessionTTLBase + time.Duration(jitterMinutes)*time.Minute
newID := uuid.New().String()
cliSessionCache[accountID] = cliSessionEntry{
id: newID,
expiresAt: now.Add(ttl),
}
return newID
}
// reUserHome matches /Users/<username>/ or /home/<username>/ path segments.
// Captures the prefix (/Users/ or /home/) so we can preserve it while replacing the username.
var reUserHome = regexp.MustCompile(`(/(Users|home)/)[^/\s"']+/`)
// reEnvLine matches lines of the form "Key: value" for the environment block
// fields injected by Claude Code's CLAUDE.md / sysprompt machinery.
var reEnvLine = regexp.MustCompile(`(?m)^(Platform|Shell|OS Version|Working directory):.*$`)
// canonicalEnvValues maps environment block keys to their canonical replacements.
// Values mirror cc-gateway's prompt_env config and represent a stock macOS dev machine.
var canonicalEnvValues = map[string]string{
"Platform": "Platform: darwin",
"Shell": "Shell: zsh",
"OS Version": "OS Version: Darwin 24.4.0",
"Working directory": "Working directory: /Users/user/project",
}
// NormalizeSystemPromptEnv rewrites environment-specific fields in a system
// prompt text block to canonical values, preventing real machine fingerprinting.
//
// Handles two classes of leakage (matching cc-gateway rewriter.ts:rewritePromptText):
// 1. "Platform: Windows / Linux / Darwin 25.x" → canonical darwin/zsh/Darwin 24.4.0
// 2. "/Users/alice/" or "/home/bob/" → "/Users/user/"
//
// Only called on system prompt text blocks, never on user message content.
func NormalizeSystemPromptEnv(text string) string {
// Replace env-info lines with canonical values
text = reEnvLine.ReplaceAllStringFunc(text, func(line string) string {
for key, canonical := range canonicalEnvValues {
if len(line) >= len(key) && line[:len(key)] == key {
return canonical
}
}
return line
})
// Redact real usernames in home directory paths
// e.g. /Users/alice/project -> /Users/user/project
text = reUserHome.ReplaceAllString(text, "${1}user/")
return text
}