mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-03 01:40:21 +08:00
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:
@@ -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",
|
||||
]
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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")),
|
||||
)
|
||||
|
||||
|
||||
@@ -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),
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user