feat(claude-code): 增加 TLS 指纹伪装、Cache TTL 统一、CLI 限制与流超时冷却

- 新增 curl_cffi Transport,支持真实浏览器 TLS 指纹伪装 (Chrome/Node.js)
- 增加 Cache TTL Override 功能,强制统一 cache_control 类型防止行为指纹差异
- 增加 CLI-only 客户端限制,支持仅允许 Claude Code CLI 访问
- 池健康策略增加 stream timeout 计数与自动冷却机制
- OAuth 账号 Region 选择改为从 AWS API 动态获取,支持搜索和自定义输入
- 前端 PoolConfigDialog 增加对应配置 UI
This commit is contained in:
fawney19
2026-02-27 18:10:27 +08:00
parent 76a6d0ce8e
commit d2450cbdc1
19 changed files with 870 additions and 15 deletions

View File

@@ -0,0 +1,96 @@
"""Claude Code CLI client restriction.
When cli_only_enabled is True, only requests from genuine Claude Code CLI
clients are allowed. Non-CLI traffic receives a 403 response.
"""
from __future__ import annotations
import contextvars
from typing import Any
from src.core.logger import logger
# Contextvar to carry the original request headers into the envelope layer.
_original_request_headers: contextvars.ContextVar[dict[str, str] | None] = contextvars.ContextVar(
"claude_code_original_request_headers",
default=None,
)
def set_original_request_headers(headers: dict[str, str] | None) -> None:
_original_request_headers.set(headers)
def get_original_request_headers() -> dict[str, str] | None:
return _original_request_headers.get()
# Known Claude Code CLI User-Agent patterns.
_CLI_USER_AGENT_PATTERNS = (
"claude-code",
"claudecode",
"claude_code",
)
# Known originator / x-app values indicating CLI usage.
_CLI_APP_VALUES = {"cli"}
def is_claude_code_client(headers: dict[str, Any]) -> bool:
"""Detect whether the request originates from a Claude Code CLI client.
Detection signals (any match is sufficient):
1. User-Agent contains a known Claude Code CLI pattern
2. x-app header equals "cli"
"""
# Normalize header keys to lowercase for case-insensitive matching.
lower_headers = {k.lower(): v for k, v in headers.items()}
# Check User-Agent
ua = str(lower_headers.get("user-agent", "")).lower()
for pattern in _CLI_USER_AGENT_PATTERNS:
if pattern in ua:
return True
# Check x-app header
x_app = str(lower_headers.get("x-app", "")).strip().lower()
if x_app in _CLI_APP_VALUES:
return True
return False
def enforce_cli_only(cli_only_enabled: bool) -> None:
"""Enforce CLI-only restriction if enabled.
Reads original request headers from contextvar, checks whether the
client is a Claude Code CLI, and raises HTTPException(403) if not.
"""
if not cli_only_enabled:
return
headers = get_original_request_headers()
if headers is None:
# No headers available; skip enforcement (should not happen in normal flow).
logger.debug("CLI-only check skipped: no request headers in context")
return
if is_claude_code_client(headers):
return
from fastapi import HTTPException
logger.info("CLI-only restriction: rejected non-CLI client")
raise HTTPException(
status_code=403,
detail="This endpoint only accepts requests from Claude Code CLI clients.",
)
__all__ = [
"enforce_cli_only",
"get_original_request_headers",
"is_claude_code_client",
"set_original_request_headers",
]

View File

@@ -24,6 +24,9 @@ class ClaudeCodeRequestContext:
session_idle_timeout_minutes: int = 5
enable_tls_fingerprint: bool = False
session_id_masking_enabled: bool = False
cache_ttl_override_enabled: bool = False
cache_ttl_override_target: str = "ephemeral"
cli_only_enabled: bool = False
# Account Pool fields
provider_id: str | None = None
pool_config: PoolConfig | None = None
@@ -88,6 +91,15 @@ def build_claude_code_request_context(
session_id_masking_enabled = (
bool(advanced_config.session_id_masking_enabled) if advanced_config else False
)
cache_ttl_override_enabled = (
bool(advanced_config.cache_ttl_override_enabled) if advanced_config else False
)
cache_ttl_override_target = (
str(advanced_config.cache_ttl_override_target or "ephemeral")
if advanced_config
else "ephemeral"
)
cli_only_enabled = bool(advanced_config.cli_only_enabled) if advanced_config else False
# Parse pool config (None = non-pool provider, keep as None for semantic consistency)
pool_cfg = parse_pool_config(provider_config_dict)
@@ -100,6 +112,9 @@ def build_claude_code_request_context(
session_idle_timeout_minutes=idle_timeout_minutes,
enable_tls_fingerprint=enable_tls_fingerprint,
session_id_masking_enabled=session_id_masking_enabled,
cache_ttl_override_enabled=cache_ttl_override_enabled,
cache_ttl_override_target=cache_ttl_override_target,
cli_only_enabled=cli_only_enabled,
provider_id=str(provider_id or "").strip() or None,
pool_config=pool_cfg,
)

View File

@@ -218,6 +218,59 @@ def _apply_session_id_masking(request_body: dict[str, Any], *, scope_key: str) -
)
# -- Cache TTL Override -------------------------------------------------------
_VALID_CACHE_TTL_TARGETS = {"ephemeral", "1h"}
def _override_cache_control_in_blocks(blocks: list[Any], target: str) -> int:
"""Override cache_control in a list of content blocks. Returns count of overrides."""
count = 0
for block in blocks:
if not isinstance(block, dict):
continue
cc = block.get("cache_control")
if isinstance(cc, dict):
if cc.get("type") != target:
cc["type"] = target
count += 1
return count
def _apply_cache_ttl_override(request_body: dict[str, Any], target: str) -> None:
"""Force all cache_control entries to use a unified TTL type.
Prevents multi-user behavioral fingerprinting when sharing an OAuth account.
"""
if target not in _VALID_CACHE_TTL_TARGETS:
return
overridden = 0
# system prompt (can be string or list of blocks)
system = request_body.get("system")
if isinstance(system, list):
overridden += _override_cache_control_in_blocks(system, target)
# messages
messages = request_body.get("messages")
if isinstance(messages, list):
for msg in messages:
if not isinstance(msg, dict):
continue
content = msg.get("content")
if isinstance(content, list):
overridden += _override_cache_control_in_blocks(content, target)
# tools
tools = request_body.get("tools")
if isinstance(tools, list):
overridden += _override_cache_control_in_blocks(tools, target)
if overridden:
logger.debug("Cache TTL override: {} block(s) -> {}", overridden, target)
def _register_or_reject_session(
*,
scope_key: str,
@@ -446,6 +499,15 @@ class ClaudeCodeEnvelope:
ctx = get_claude_code_request_context()
if ctx is None:
ctx = ClaudeCodeRequestContext()
# CLI-only restriction: reject non-CLI clients early.
if ctx.cli_only_enabled:
from src.services.provider.adapters.claude_code.client_restriction import (
enforce_cli_only,
)
enforce_cli_only(ctx.cli_only_enabled)
# Extract session_uuid from metadata.user_id for pool sticky session.
session_uuid: str | None = None
user_id = _get_metadata_user_id(request_body)
@@ -456,6 +518,10 @@ class ClaudeCodeEnvelope:
_sanitize_thinking_blocks(request_body)
# Cache TTL override: unify cache_control types to prevent behavioral fingerprinting.
if ctx.cache_ttl_override_enabled:
_apply_cache_ttl_override(request_body, ctx.cache_ttl_override_target)
_enforce_session_controls(
request_body,
ctx,

View File

@@ -51,6 +51,11 @@ class PoolConfig:
# -- Temporary Unschedulable Rules ----------------------------------------
unschedulable_rules: list[UnschedulableRule] = field(default_factory=list)
# -- Stream Timeout Auto-Pause --------------------------------------------
stream_timeout_threshold: int = 3 # N timeouts within window trigger cooldown
stream_timeout_window_seconds: int = 1800 # 30 min counting window
stream_timeout_cooldown_seconds: int = 300 # 5 min cooldown
# -- Pluggable Strategies -------------------------------------------------
strategies: tuple[str, ...] = ()
@@ -127,6 +132,9 @@ def parse_pool_config(provider_config: Any) -> PoolConfig | None:
proactive_refresh_seconds=_int_or("proactive_refresh_seconds", 180),
health_policy_enabled=_bool_or("health_policy_enabled", True),
unschedulable_rules=rules,
stream_timeout_threshold=_int_or("stream_timeout_threshold", 3),
stream_timeout_window_seconds=_int_or("stream_timeout_window_seconds", 1800),
stream_timeout_cooldown_seconds=_int_or("stream_timeout_cooldown_seconds", 300),
strategies=_parse_strategies(raw_advanced.get("strategies")),
)

View File

@@ -202,3 +202,56 @@ async def _apply(
rule.duration_minutes,
)
return
async def apply_stream_timeout_policy(
*,
provider_id: str,
key_id: str,
config: PoolConfig,
) -> None:
"""Record a stream timeout event and apply cooldown if threshold is reached.
Called when an upstream stream response times out (no data within the
configured interval). Increments a per-key counter in Redis and sets
a cooldown if the count reaches the configured threshold.
"""
if not config.health_policy_enabled:
return
try:
count = await redis_ops.incr_stream_timeout_count(
provider_id,
key_id,
config.stream_timeout_window_seconds,
)
if count >= config.stream_timeout_threshold:
ttl = config.stream_timeout_cooldown_seconds
await redis_ops.set_cooldown(
provider_id,
key_id,
f"stream_timeout_x{count}",
ttl=ttl,
)
logger.warning(
"Pool[{}]: key {} stream timeout count {} >= threshold {}, cooldown {}s",
provider_id[:8],
key_id[:8],
count,
config.stream_timeout_threshold,
ttl,
)
else:
logger.info(
"Pool[{}]: key {} stream timeout count {}/{}",
provider_id[:8],
key_id[:8],
count,
config.stream_timeout_threshold,
)
except Exception as exc:
logger.warning(
"Pool stream timeout policy failed for key {}: {}",
key_id[:8],
str(exc),
)

View File

@@ -468,3 +468,45 @@ async def batch_get_cooldown_ttls(provider_id: str, key_ids: list[str]) -> dict[
return out
except Exception:
return {k: None for k in key_ids}
# ---------------------------------------------------------------------------
# Stream timeout counter
# ---------------------------------------------------------------------------
_STREAM_TIMEOUT_KEY_FMT = f"{PREFIX}:{{}}:stream_timeout:{{}}"
def _stream_timeout_key(provider_id: str, key_id: str) -> str:
return _STREAM_TIMEOUT_KEY_FMT.format(provider_id, key_id)
async def incr_stream_timeout_count(
provider_id: str,
key_id: str,
window_seconds: int,
) -> int:
"""Increment stream timeout counter and return count within the window.
Uses a ZSET with timestamps as scores. Old entries beyond the window
are pruned on each call. Returns the count of timeouts in the window.
"""
redis = await _get_redis()
if redis is None:
return 0
try:
now = time.time()
window_start = now - window_seconds
key = _stream_timeout_key(provider_id, key_id)
member = f"{uuid.uuid4().hex}"
pipe = redis.pipeline()
pipe.zremrangebyscore(key, "-inf", window_start)
pipe.zadd(key, {member: now})
pipe.zcard(key)
pipe.expire(key, window_seconds + 60)
results = await pipe.execute()
count = int(results[2]) if results[2] else 0
return count
except Exception:
logger.debug("Pool: stream timeout INCR failed for key {}", key_id[:8])
return 0