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>
254 lines
8.5 KiB
Go
254 lines
8.5 KiB
Go
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
|
||
}
|