Merge pull request #2489 from Soein/upstream-pr
fix: 增强 Claude 反代检测对抗能力
This commit is contained in:
@@ -88,7 +88,7 @@ func (o *ClaudeAuth) GenerateAuthURL(state string, pkceCodes *PKCECodes) (string
|
|||||||
"client_id": {ClientID},
|
"client_id": {ClientID},
|
||||||
"response_type": {"code"},
|
"response_type": {"code"},
|
||||||
"redirect_uri": {RedirectURI},
|
"redirect_uri": {RedirectURI},
|
||||||
"scope": {"org:create_api_key user:profile user:inference"},
|
"scope": {"user:profile user:inference user:sessions:claude_code user:mcp_servers user:file_upload"},
|
||||||
"code_challenge": {pkceCodes.CodeChallenge},
|
"code_challenge": {pkceCodes.CodeChallenge},
|
||||||
"code_challenge_method": {"S256"},
|
"code_challenge_method": {"S256"},
|
||||||
"state": {state},
|
"state": {state},
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ import (
|
|||||||
"compress/flate"
|
"compress/flate"
|
||||||
"compress/gzip"
|
"compress/gzip"
|
||||||
"context"
|
"context"
|
||||||
"crypto/rand"
|
|
||||||
"crypto/sha256"
|
"crypto/sha256"
|
||||||
"encoding/hex"
|
"encoding/hex"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
@@ -18,6 +17,7 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/andybalholm/brotli"
|
"github.com/andybalholm/brotli"
|
||||||
|
"github.com/google/uuid"
|
||||||
"github.com/klauspost/compress/zstd"
|
"github.com/klauspost/compress/zstd"
|
||||||
claudeauth "github.com/router-for-me/CLIProxyAPI/v6/internal/auth/claude"
|
claudeauth "github.com/router-for-me/CLIProxyAPI/v6/internal/auth/claude"
|
||||||
"github.com/router-for-me/CLIProxyAPI/v6/internal/config"
|
"github.com/router-for-me/CLIProxyAPI/v6/internal/config"
|
||||||
@@ -92,7 +92,7 @@ func (e *ClaudeExecutor) HttpRequest(ctx context.Context, auth *cliproxyauth.Aut
|
|||||||
if err := e.PrepareRequest(httpReq, auth); err != nil {
|
if err := e.PrepareRequest(httpReq, auth); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
httpClient := helps.NewProxyAwareHTTPClient(ctx, e.cfg, auth, 0)
|
httpClient := helps.NewUtlsHTTPClient(e.cfg, auth, 0)
|
||||||
return httpClient.Do(httpReq)
|
return httpClient.Do(httpReq)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -188,7 +188,7 @@ func (e *ClaudeExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth, r
|
|||||||
AuthValue: authValue,
|
AuthValue: authValue,
|
||||||
})
|
})
|
||||||
|
|
||||||
httpClient := helps.NewProxyAwareHTTPClient(ctx, e.cfg, auth, 0)
|
httpClient := helps.NewUtlsHTTPClient(e.cfg, auth, 0)
|
||||||
httpResp, err := httpClient.Do(httpReq)
|
httpResp, err := httpClient.Do(httpReq)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
helps.RecordAPIResponseError(ctx, e.cfg, err)
|
helps.RecordAPIResponseError(ctx, e.cfg, err)
|
||||||
@@ -355,7 +355,7 @@ func (e *ClaudeExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.A
|
|||||||
AuthValue: authValue,
|
AuthValue: authValue,
|
||||||
})
|
})
|
||||||
|
|
||||||
httpClient := helps.NewProxyAwareHTTPClient(ctx, e.cfg, auth, 0)
|
httpClient := helps.NewUtlsHTTPClient(e.cfg, auth, 0)
|
||||||
httpResp, err := httpClient.Do(httpReq)
|
httpResp, err := httpClient.Do(httpReq)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
helps.RecordAPIResponseError(ctx, e.cfg, err)
|
helps.RecordAPIResponseError(ctx, e.cfg, err)
|
||||||
@@ -522,7 +522,7 @@ func (e *ClaudeExecutor) CountTokens(ctx context.Context, auth *cliproxyauth.Aut
|
|||||||
AuthValue: authValue,
|
AuthValue: authValue,
|
||||||
})
|
})
|
||||||
|
|
||||||
httpClient := helps.NewProxyAwareHTTPClient(ctx, e.cfg, auth, 0)
|
httpClient := helps.NewUtlsHTTPClient(e.cfg, auth, 0)
|
||||||
resp, err := httpClient.Do(httpReq)
|
resp, err := httpClient.Do(httpReq)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
helps.RecordAPIResponseError(ctx, e.cfg, err)
|
helps.RecordAPIResponseError(ctx, e.cfg, err)
|
||||||
@@ -813,7 +813,7 @@ func applyClaudeHeaders(r *http.Request, auth *cliproxyauth.Auth, apiKey string,
|
|||||||
deviceProfile = helps.ResolveClaudeDeviceProfile(auth, apiKey, ginHeaders, cfg)
|
deviceProfile = helps.ResolveClaudeDeviceProfile(auth, apiKey, ginHeaders, cfg)
|
||||||
}
|
}
|
||||||
|
|
||||||
baseBetas := "claude-code-20250219,oauth-2025-04-20,interleaved-thinking-2025-05-14,context-management-2025-06-27,prompt-caching-scope-2026-01-05"
|
baseBetas := "claude-code-20250219,oauth-2025-04-20,interleaved-thinking-2025-05-14,context-management-2025-06-27,prompt-caching-scope-2026-01-05,structured-outputs-2025-12-15,fast-mode-2026-02-01,redact-thinking-2026-02-12,token-efficient-tools-2026-03-28"
|
||||||
if val := strings.TrimSpace(ginHeaders.Get("Anthropic-Beta")); val != "" {
|
if val := strings.TrimSpace(ginHeaders.Get("Anthropic-Beta")); val != "" {
|
||||||
baseBetas = val
|
baseBetas = val
|
||||||
if !strings.Contains(val, "oauth") {
|
if !strings.Contains(val, "oauth") {
|
||||||
@@ -851,13 +851,22 @@ func applyClaudeHeaders(r *http.Request, auth *cliproxyauth.Auth, apiKey string,
|
|||||||
r.Header.Set("Anthropic-Beta", baseBetas)
|
r.Header.Set("Anthropic-Beta", baseBetas)
|
||||||
|
|
||||||
misc.EnsureHeader(r.Header, ginHeaders, "Anthropic-Version", "2023-06-01")
|
misc.EnsureHeader(r.Header, ginHeaders, "Anthropic-Version", "2023-06-01")
|
||||||
misc.EnsureHeader(r.Header, ginHeaders, "Anthropic-Dangerous-Direct-Browser-Access", "true")
|
// Only set browser access header for API key mode; real Claude Code CLI does not send it.
|
||||||
|
if useAPIKey {
|
||||||
|
misc.EnsureHeader(r.Header, ginHeaders, "Anthropic-Dangerous-Direct-Browser-Access", "true")
|
||||||
|
}
|
||||||
misc.EnsureHeader(r.Header, ginHeaders, "X-App", "cli")
|
misc.EnsureHeader(r.Header, ginHeaders, "X-App", "cli")
|
||||||
// Values below match Claude Code 2.1.63 / @anthropic-ai/sdk 0.74.0 (updated 2026-02-28).
|
// Values below match Claude Code 2.1.63 / @anthropic-ai/sdk 0.74.0 (updated 2026-02-28).
|
||||||
misc.EnsureHeader(r.Header, ginHeaders, "X-Stainless-Retry-Count", "0")
|
misc.EnsureHeader(r.Header, ginHeaders, "X-Stainless-Retry-Count", "0")
|
||||||
misc.EnsureHeader(r.Header, ginHeaders, "X-Stainless-Runtime", "node")
|
misc.EnsureHeader(r.Header, ginHeaders, "X-Stainless-Runtime", "node")
|
||||||
misc.EnsureHeader(r.Header, ginHeaders, "X-Stainless-Lang", "js")
|
misc.EnsureHeader(r.Header, ginHeaders, "X-Stainless-Lang", "js")
|
||||||
misc.EnsureHeader(r.Header, ginHeaders, "X-Stainless-Timeout", hdrDefault(hd.Timeout, "600"))
|
misc.EnsureHeader(r.Header, ginHeaders, "X-Stainless-Timeout", hdrDefault(hd.Timeout, "600"))
|
||||||
|
// Session ID: stable per auth/apiKey, matches Claude Code's X-Claude-Code-Session-Id header.
|
||||||
|
misc.EnsureHeader(r.Header, ginHeaders, "X-Claude-Code-Session-Id", helps.CachedSessionID(apiKey))
|
||||||
|
// Per-request UUID, matches Claude Code's x-client-request-id for first-party API.
|
||||||
|
if isAnthropicBase {
|
||||||
|
misc.EnsureHeader(r.Header, ginHeaders, "x-client-request-id", uuid.New().String())
|
||||||
|
}
|
||||||
r.Header.Set("Connection", "keep-alive")
|
r.Header.Set("Connection", "keep-alive")
|
||||||
if stream {
|
if stream {
|
||||||
r.Header.Set("Accept", "text/event-stream")
|
r.Header.Set("Accept", "text/event-stream")
|
||||||
@@ -907,7 +916,7 @@ func claudeCreds(a *cliproxyauth.Auth) (apiKey, baseURL string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func checkSystemInstructions(payload []byte) []byte {
|
func checkSystemInstructions(payload []byte) []byte {
|
||||||
return checkSystemInstructionsWithSigningMode(payload, false, false)
|
return checkSystemInstructionsWithSigningMode(payload, false, false, "2.1.63", "", "")
|
||||||
}
|
}
|
||||||
|
|
||||||
func isClaudeOAuthToken(apiKey string) bool {
|
func isClaudeOAuthToken(apiKey string) bool {
|
||||||
@@ -1102,6 +1111,38 @@ func getClientUserAgent(ctx context.Context) string {
|
|||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// parseEntrypointFromUA extracts the entrypoint from a Claude Code User-Agent.
|
||||||
|
// Format: "claude-cli/x.y.z (external, cli)" → "cli"
|
||||||
|
// Format: "claude-cli/x.y.z (external, vscode)" → "vscode"
|
||||||
|
// Returns "cli" if parsing fails or UA is not Claude Code.
|
||||||
|
func parseEntrypointFromUA(userAgent string) string {
|
||||||
|
// Find content inside parentheses
|
||||||
|
start := strings.Index(userAgent, "(")
|
||||||
|
end := strings.LastIndex(userAgent, ")")
|
||||||
|
if start < 0 || end <= start {
|
||||||
|
return "cli"
|
||||||
|
}
|
||||||
|
inner := userAgent[start+1 : end]
|
||||||
|
// Split by comma, take the second part (entrypoint is at index 1, after USER_TYPE)
|
||||||
|
// Format: "(USER_TYPE, ENTRYPOINT[, extra...])"
|
||||||
|
parts := strings.Split(inner, ",")
|
||||||
|
if len(parts) >= 2 {
|
||||||
|
ep := strings.TrimSpace(parts[1])
|
||||||
|
if ep != "" {
|
||||||
|
return ep
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "cli"
|
||||||
|
}
|
||||||
|
|
||||||
|
// getWorkloadFromContext extracts workload identifier from the gin request headers.
|
||||||
|
func getWorkloadFromContext(ctx context.Context) string {
|
||||||
|
if ginCtx, ok := ctx.Value("gin").(*gin.Context); ok && ginCtx != nil && ginCtx.Request != nil {
|
||||||
|
return strings.TrimSpace(ginCtx.GetHeader("X-CPA-Claude-Workload"))
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
// getCloakConfigFromAuth extracts cloak configuration from auth attributes.
|
// getCloakConfigFromAuth extracts cloak configuration from auth attributes.
|
||||||
// Returns (cloakMode, strictMode, sensitiveWords, cacheUserID).
|
// Returns (cloakMode, strictMode, sensitiveWords, cacheUserID).
|
||||||
func getCloakConfigFromAuth(auth *cliproxyauth.Auth) (string, bool, []string, bool) {
|
func getCloakConfigFromAuth(auth *cliproxyauth.Auth) (string, bool, []string, bool) {
|
||||||
@@ -1152,28 +1193,52 @@ func injectFakeUserID(payload []byte, apiKey string, useCache bool) []byte {
|
|||||||
return payload
|
return payload
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// fingerprintSalt is the salt used by Claude Code to compute the 3-char build fingerprint.
|
||||||
|
const fingerprintSalt = "59cf53e54c78"
|
||||||
|
|
||||||
|
// computeFingerprint computes the 3-char build fingerprint that Claude Code embeds in cc_version.
|
||||||
|
// Algorithm: SHA256(salt + messageText[4] + messageText[7] + messageText[20] + version)[:3]
|
||||||
|
func computeFingerprint(messageText, version string) string {
|
||||||
|
indices := [3]int{4, 7, 20}
|
||||||
|
runes := []rune(messageText)
|
||||||
|
var sb strings.Builder
|
||||||
|
for _, idx := range indices {
|
||||||
|
if idx < len(runes) {
|
||||||
|
sb.WriteRune(runes[idx])
|
||||||
|
} else {
|
||||||
|
sb.WriteRune('0')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
input := fingerprintSalt + sb.String() + version
|
||||||
|
h := sha256.Sum256([]byte(input))
|
||||||
|
return hex.EncodeToString(h[:])[:3]
|
||||||
|
}
|
||||||
|
|
||||||
// generateBillingHeader creates the x-anthropic-billing-header text block that
|
// generateBillingHeader creates the x-anthropic-billing-header text block that
|
||||||
// real Claude Code prepends to every system prompt array.
|
// real Claude Code prepends to every system prompt array.
|
||||||
// Format: x-anthropic-billing-header: cc_version=<ver>.<build>; cc_entrypoint=cli; cch=<hash>;
|
// Format: x-anthropic-billing-header: cc_version=<ver>.<build>; cc_entrypoint=<ep>; cch=<hash>; [cc_workload=<wl>;]
|
||||||
func generateBillingHeader(payload []byte, experimentalCCHSigning bool) string {
|
func generateBillingHeader(payload []byte, experimentalCCHSigning bool, version, messageText, entrypoint, workload string) string {
|
||||||
// Build hash: 3-char hex, matches the pattern seen in real requests (e.g. "a43")
|
if entrypoint == "" {
|
||||||
buildBytes := make([]byte, 2)
|
entrypoint = "cli"
|
||||||
_, _ = rand.Read(buildBytes)
|
}
|
||||||
buildHash := hex.EncodeToString(buildBytes)[:3]
|
buildHash := computeFingerprint(messageText, version)
|
||||||
|
workloadPart := ""
|
||||||
|
if workload != "" {
|
||||||
|
workloadPart = fmt.Sprintf(" cc_workload=%s;", workload)
|
||||||
|
}
|
||||||
|
|
||||||
if experimentalCCHSigning {
|
if experimentalCCHSigning {
|
||||||
return fmt.Sprintf("x-anthropic-billing-header: cc_version=2.1.63.%s; cc_entrypoint=cli; cch=00000;", buildHash)
|
return fmt.Sprintf("x-anthropic-billing-header: cc_version=%s.%s; cc_entrypoint=%s; cch=00000;%s", version, buildHash, entrypoint, workloadPart)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Generate a deterministic cch hash from the payload content (system + messages + tools).
|
// Generate a deterministic cch hash from the payload content (system + messages + tools).
|
||||||
// Real Claude Code uses a 5-char hex hash that varies per request.
|
|
||||||
h := sha256.Sum256(payload)
|
h := sha256.Sum256(payload)
|
||||||
cch := hex.EncodeToString(h[:])[:5]
|
cch := hex.EncodeToString(h[:])[:5]
|
||||||
return fmt.Sprintf("x-anthropic-billing-header: cc_version=2.1.63.%s; cc_entrypoint=cli; cch=%s;", buildHash, cch)
|
return fmt.Sprintf("x-anthropic-billing-header: cc_version=%s.%s; cc_entrypoint=%s; cch=%s;%s", version, buildHash, entrypoint, cch, workloadPart)
|
||||||
}
|
}
|
||||||
|
|
||||||
func checkSystemInstructionsWithMode(payload []byte, strictMode bool) []byte {
|
func checkSystemInstructionsWithMode(payload []byte, strictMode bool) []byte {
|
||||||
return checkSystemInstructionsWithSigningMode(payload, strictMode, false)
|
return checkSystemInstructionsWithSigningMode(payload, strictMode, false, "2.1.63", "", "")
|
||||||
}
|
}
|
||||||
|
|
||||||
// checkSystemInstructionsWithSigningMode injects Claude Code-style system blocks:
|
// checkSystemInstructionsWithSigningMode injects Claude Code-style system blocks:
|
||||||
@@ -1181,10 +1246,25 @@ func checkSystemInstructionsWithMode(payload []byte, strictMode bool) []byte {
|
|||||||
// system[0]: billing header (no cache_control)
|
// system[0]: billing header (no cache_control)
|
||||||
// system[1]: agent identifier (no cache_control)
|
// system[1]: agent identifier (no cache_control)
|
||||||
// system[2..]: user system messages (cache_control added when missing)
|
// system[2..]: user system messages (cache_control added when missing)
|
||||||
func checkSystemInstructionsWithSigningMode(payload []byte, strictMode bool, experimentalCCHSigning bool) []byte {
|
func checkSystemInstructionsWithSigningMode(payload []byte, strictMode bool, experimentalCCHSigning bool, version, entrypoint, workload string) []byte {
|
||||||
system := gjson.GetBytes(payload, "system")
|
system := gjson.GetBytes(payload, "system")
|
||||||
|
|
||||||
billingText := generateBillingHeader(payload, experimentalCCHSigning)
|
// Extract original message text for fingerprint computation (before billing injection).
|
||||||
|
// Use the first system text block's content as the fingerprint source.
|
||||||
|
messageText := ""
|
||||||
|
if system.IsArray() {
|
||||||
|
system.ForEach(func(_, part gjson.Result) bool {
|
||||||
|
if part.Get("type").String() == "text" {
|
||||||
|
messageText = part.Get("text").String()
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
} else if system.Type == gjson.String {
|
||||||
|
messageText = system.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
billingText := generateBillingHeader(payload, experimentalCCHSigning, version, messageText, entrypoint, workload)
|
||||||
billingBlock := fmt.Sprintf(`{"type":"text","text":"%s"}`, billingText)
|
billingBlock := fmt.Sprintf(`{"type":"text","text":"%s"}`, billingText)
|
||||||
// No cache_control on the agent block. It is a cloaking artifact with zero cache
|
// No cache_control on the agent block. It is a cloaking artifact with zero cache
|
||||||
// value (the last system block is what actually triggers caching of all system content).
|
// value (the last system block is what actually triggers caching of all system content).
|
||||||
@@ -1273,7 +1353,10 @@ func applyCloaking(ctx context.Context, cfg *config.Config, auth *cliproxyauth.A
|
|||||||
|
|
||||||
// Skip system instructions for claude-3-5-haiku models
|
// Skip system instructions for claude-3-5-haiku models
|
||||||
if !strings.HasPrefix(model, "claude-3-5-haiku") {
|
if !strings.HasPrefix(model, "claude-3-5-haiku") {
|
||||||
payload = checkSystemInstructionsWithSigningMode(payload, strictMode, useExperimentalCCHSigning)
|
billingVersion := helps.DefaultClaudeVersion(cfg)
|
||||||
|
entrypoint := parseEntrypointFromUA(clientUserAgent)
|
||||||
|
workload := getWorkloadFromContext(ctx)
|
||||||
|
payload = checkSystemInstructionsWithSigningMode(payload, strictMode, useExperimentalCCHSigning, billingVersion, entrypoint, workload)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Inject fake user ID
|
// Inject fake user ID
|
||||||
|
|||||||
@@ -358,6 +358,16 @@ func ApplyClaudeDeviceProfileHeaders(r *http.Request, profile ClaudeDeviceProfil
|
|||||||
r.Header.Set("X-Stainless-Arch", profile.Arch)
|
r.Header.Set("X-Stainless-Arch", profile.Arch)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// DefaultClaudeVersion returns the version string (e.g. "2.1.63") from the
|
||||||
|
// current baseline device profile. It extracts the version from the User-Agent.
|
||||||
|
func DefaultClaudeVersion(cfg *config.Config) string {
|
||||||
|
profile := defaultClaudeDeviceProfile(cfg)
|
||||||
|
if version, ok := parseClaudeCLIVersion(profile.UserAgent); ok {
|
||||||
|
return strconv.Itoa(version.major) + "." + strconv.Itoa(version.minor) + "." + strconv.Itoa(version.patch)
|
||||||
|
}
|
||||||
|
return "2.1.63"
|
||||||
|
}
|
||||||
|
|
||||||
func ApplyClaudeLegacyDeviceHeaders(r *http.Request, ginHeaders http.Header, cfg *config.Config) {
|
func ApplyClaudeLegacyDeviceHeaders(r *http.Request, ginHeaders http.Header, cfg *config.Config) {
|
||||||
if r == nil {
|
if r == nil {
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -0,0 +1,92 @@
|
|||||||
|
package helps
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/hex"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
)
|
||||||
|
|
||||||
|
type sessionIDCacheEntry struct {
|
||||||
|
value string
|
||||||
|
expire time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
sessionIDCache = make(map[string]sessionIDCacheEntry)
|
||||||
|
sessionIDCacheMu sync.RWMutex
|
||||||
|
sessionIDCacheCleanupOnce sync.Once
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
sessionIDTTL = time.Hour
|
||||||
|
sessionIDCacheCleanupPeriod = 15 * time.Minute
|
||||||
|
)
|
||||||
|
|
||||||
|
func startSessionIDCacheCleanup() {
|
||||||
|
go func() {
|
||||||
|
ticker := time.NewTicker(sessionIDCacheCleanupPeriod)
|
||||||
|
defer ticker.Stop()
|
||||||
|
for range ticker.C {
|
||||||
|
purgeExpiredSessionIDs()
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
func purgeExpiredSessionIDs() {
|
||||||
|
now := time.Now()
|
||||||
|
sessionIDCacheMu.Lock()
|
||||||
|
for key, entry := range sessionIDCache {
|
||||||
|
if !entry.expire.After(now) {
|
||||||
|
delete(sessionIDCache, key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sessionIDCacheMu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
func sessionIDCacheKey(apiKey string) string {
|
||||||
|
sum := sha256.Sum256([]byte(apiKey))
|
||||||
|
return hex.EncodeToString(sum[:])
|
||||||
|
}
|
||||||
|
|
||||||
|
// CachedSessionID returns a stable session UUID per apiKey, refreshing the TTL on each access.
|
||||||
|
func CachedSessionID(apiKey string) string {
|
||||||
|
if apiKey == "" {
|
||||||
|
return uuid.New().String()
|
||||||
|
}
|
||||||
|
|
||||||
|
sessionIDCacheCleanupOnce.Do(startSessionIDCacheCleanup)
|
||||||
|
|
||||||
|
key := sessionIDCacheKey(apiKey)
|
||||||
|
now := time.Now()
|
||||||
|
|
||||||
|
sessionIDCacheMu.RLock()
|
||||||
|
entry, ok := sessionIDCache[key]
|
||||||
|
valid := ok && entry.value != "" && entry.expire.After(now)
|
||||||
|
sessionIDCacheMu.RUnlock()
|
||||||
|
if valid {
|
||||||
|
sessionIDCacheMu.Lock()
|
||||||
|
entry = sessionIDCache[key]
|
||||||
|
if entry.value != "" && entry.expire.After(now) {
|
||||||
|
entry.expire = now.Add(sessionIDTTL)
|
||||||
|
sessionIDCache[key] = entry
|
||||||
|
sessionIDCacheMu.Unlock()
|
||||||
|
return entry.value
|
||||||
|
}
|
||||||
|
sessionIDCacheMu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
newID := uuid.New().String()
|
||||||
|
|
||||||
|
sessionIDCacheMu.Lock()
|
||||||
|
entry, ok = sessionIDCache[key]
|
||||||
|
if !ok || entry.value == "" || !entry.expire.After(now) {
|
||||||
|
entry.value = newID
|
||||||
|
}
|
||||||
|
entry.expire = now.Add(sessionIDTTL)
|
||||||
|
sessionIDCache[key] = entry
|
||||||
|
sessionIDCacheMu.Unlock()
|
||||||
|
return entry.value
|
||||||
|
}
|
||||||
@@ -0,0 +1,188 @@
|
|||||||
|
package helps
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
tls "github.com/refraction-networking/utls"
|
||||||
|
"github.com/router-for-me/CLIProxyAPI/v6/internal/config"
|
||||||
|
cliproxyauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth"
|
||||||
|
"github.com/router-for-me/CLIProxyAPI/v6/sdk/proxyutil"
|
||||||
|
log "github.com/sirupsen/logrus"
|
||||||
|
"golang.org/x/net/http2"
|
||||||
|
"golang.org/x/net/proxy"
|
||||||
|
)
|
||||||
|
|
||||||
|
// utlsRoundTripper implements http.RoundTripper using utls with Chrome fingerprint
|
||||||
|
// to bypass Cloudflare's TLS fingerprinting on Anthropic domains.
|
||||||
|
type utlsRoundTripper struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
connections map[string]*http2.ClientConn
|
||||||
|
pending map[string]*sync.Cond
|
||||||
|
dialer proxy.Dialer
|
||||||
|
}
|
||||||
|
|
||||||
|
func newUtlsRoundTripper(proxyURL string) *utlsRoundTripper {
|
||||||
|
var dialer proxy.Dialer = proxy.Direct
|
||||||
|
if proxyURL != "" {
|
||||||
|
proxyDialer, mode, errBuild := proxyutil.BuildDialer(proxyURL)
|
||||||
|
if errBuild != nil {
|
||||||
|
log.Errorf("utls: failed to configure proxy dialer for %q: %v", proxyURL, errBuild)
|
||||||
|
} else if mode != proxyutil.ModeInherit && proxyDialer != nil {
|
||||||
|
dialer = proxyDialer
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return &utlsRoundTripper{
|
||||||
|
connections: make(map[string]*http2.ClientConn),
|
||||||
|
pending: make(map[string]*sync.Cond),
|
||||||
|
dialer: dialer,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *utlsRoundTripper) getOrCreateConnection(host, addr string) (*http2.ClientConn, error) {
|
||||||
|
t.mu.Lock()
|
||||||
|
|
||||||
|
if h2Conn, ok := t.connections[host]; ok && h2Conn.CanTakeNewRequest() {
|
||||||
|
t.mu.Unlock()
|
||||||
|
return h2Conn, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if cond, ok := t.pending[host]; ok {
|
||||||
|
cond.Wait()
|
||||||
|
if h2Conn, ok := t.connections[host]; ok && h2Conn.CanTakeNewRequest() {
|
||||||
|
t.mu.Unlock()
|
||||||
|
return h2Conn, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
cond := sync.NewCond(&t.mu)
|
||||||
|
t.pending[host] = cond
|
||||||
|
t.mu.Unlock()
|
||||||
|
|
||||||
|
h2Conn, err := t.createConnection(host, addr)
|
||||||
|
|
||||||
|
t.mu.Lock()
|
||||||
|
defer t.mu.Unlock()
|
||||||
|
|
||||||
|
delete(t.pending, host)
|
||||||
|
cond.Broadcast()
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
t.connections[host] = h2Conn
|
||||||
|
return h2Conn, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *utlsRoundTripper) createConnection(host, addr string) (*http2.ClientConn, error) {
|
||||||
|
conn, err := t.dialer.Dial("tcp", addr)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
tlsConfig := &tls.Config{ServerName: host}
|
||||||
|
tlsConn := tls.UClient(conn, tlsConfig, tls.HelloChrome_Auto)
|
||||||
|
|
||||||
|
if err := tlsConn.Handshake(); err != nil {
|
||||||
|
conn.Close()
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
tr := &http2.Transport{}
|
||||||
|
h2Conn, err := tr.NewClientConn(tlsConn)
|
||||||
|
if err != nil {
|
||||||
|
tlsConn.Close()
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return h2Conn, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *utlsRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||||
|
hostname := req.URL.Hostname()
|
||||||
|
port := req.URL.Port()
|
||||||
|
if port == "" {
|
||||||
|
port = "443"
|
||||||
|
}
|
||||||
|
addr := net.JoinHostPort(hostname, port)
|
||||||
|
|
||||||
|
h2Conn, err := t.getOrCreateConnection(hostname, addr)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := h2Conn.RoundTrip(req)
|
||||||
|
if err != nil {
|
||||||
|
t.mu.Lock()
|
||||||
|
if cached, ok := t.connections[hostname]; ok && cached == h2Conn {
|
||||||
|
delete(t.connections, hostname)
|
||||||
|
}
|
||||||
|
t.mu.Unlock()
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return resp, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// anthropicHosts contains the hosts that should use utls Chrome TLS fingerprint.
|
||||||
|
var anthropicHosts = map[string]struct{}{
|
||||||
|
"api.anthropic.com": {},
|
||||||
|
}
|
||||||
|
|
||||||
|
// fallbackRoundTripper uses utls for Anthropic HTTPS hosts and falls back to
|
||||||
|
// standard transport for all other requests (non-HTTPS or non-Anthropic hosts).
|
||||||
|
type fallbackRoundTripper struct {
|
||||||
|
utls *utlsRoundTripper
|
||||||
|
fallback http.RoundTripper
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fallbackRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||||
|
if req.URL.Scheme == "https" {
|
||||||
|
if _, ok := anthropicHosts[strings.ToLower(req.URL.Hostname())]; ok {
|
||||||
|
return f.utls.RoundTrip(req)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return f.fallback.RoundTrip(req)
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewUtlsHTTPClient creates an HTTP client using utls Chrome TLS fingerprint.
|
||||||
|
// Use this for Claude API requests to match real Claude Code's TLS behavior.
|
||||||
|
// Falls back to standard transport for non-HTTPS requests.
|
||||||
|
func NewUtlsHTTPClient(cfg *config.Config, auth *cliproxyauth.Auth, timeout time.Duration) *http.Client {
|
||||||
|
var proxyURL string
|
||||||
|
if auth != nil {
|
||||||
|
proxyURL = strings.TrimSpace(auth.ProxyURL)
|
||||||
|
}
|
||||||
|
if proxyURL == "" && cfg != nil {
|
||||||
|
proxyURL = strings.TrimSpace(cfg.ProxyURL)
|
||||||
|
}
|
||||||
|
|
||||||
|
utlsRT := newUtlsRoundTripper(proxyURL)
|
||||||
|
|
||||||
|
var standardTransport http.RoundTripper = &http.Transport{
|
||||||
|
DialContext: (&net.Dialer{
|
||||||
|
Timeout: 30 * time.Second,
|
||||||
|
KeepAlive: 30 * time.Second,
|
||||||
|
}).DialContext,
|
||||||
|
}
|
||||||
|
if proxyURL != "" {
|
||||||
|
if transport := buildProxyTransport(proxyURL); transport != nil {
|
||||||
|
standardTransport = transport
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
client := &http.Client{
|
||||||
|
Transport: &fallbackRoundTripper{
|
||||||
|
utls: utlsRT,
|
||||||
|
fallback: standardTransport,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
if timeout > 0 {
|
||||||
|
client.Timeout = timeout
|
||||||
|
}
|
||||||
|
return client
|
||||||
|
}
|
||||||
@@ -5,6 +5,18 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// gatewayHeaderPrefixes lists header name prefixes injected by known AI gateway
|
||||||
|
// proxies. Claude Code's client-side telemetry detects these and reports the
|
||||||
|
// gateway type, so we strip them from upstream responses to avoid detection.
|
||||||
|
var gatewayHeaderPrefixes = []string{
|
||||||
|
"x-litellm-",
|
||||||
|
"helicone-",
|
||||||
|
"x-portkey-",
|
||||||
|
"cf-aig-",
|
||||||
|
"x-kong-",
|
||||||
|
"x-bt-",
|
||||||
|
}
|
||||||
|
|
||||||
// hopByHopHeaders lists RFC 7230 Section 6.1 hop-by-hop headers that MUST NOT
|
// hopByHopHeaders lists RFC 7230 Section 6.1 hop-by-hop headers that MUST NOT
|
||||||
// be forwarded by proxies, plus security-sensitive headers that should not leak.
|
// be forwarded by proxies, plus security-sensitive headers that should not leak.
|
||||||
var hopByHopHeaders = map[string]struct{}{
|
var hopByHopHeaders = map[string]struct{}{
|
||||||
@@ -40,6 +52,19 @@ func FilterUpstreamHeaders(src http.Header) http.Header {
|
|||||||
if _, scoped := connectionScoped[canonicalKey]; scoped {
|
if _, scoped := connectionScoped[canonicalKey]; scoped {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
// Strip headers injected by known AI gateway proxies to avoid
|
||||||
|
// Claude Code client-side gateway detection.
|
||||||
|
lowerKey := strings.ToLower(key)
|
||||||
|
gatewayMatch := false
|
||||||
|
for _, prefix := range gatewayHeaderPrefixes {
|
||||||
|
if strings.HasPrefix(lowerKey, prefix) {
|
||||||
|
gatewayMatch = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if gatewayMatch {
|
||||||
|
continue
|
||||||
|
}
|
||||||
dst[key] = values
|
dst[key] = values
|
||||||
}
|
}
|
||||||
if len(dst) == 0 {
|
if len(dst) == 0 {
|
||||||
|
|||||||
Reference in New Issue
Block a user