refactor: 代理节点架构重构与功能增强

aether-proxy:
- 重构 main.rs,拆分为 app/state/hardware/net 模块
- setup.rs 拆分为 setup/tui.rs + setup/service.rs,支持 systemd 服务管理子命令
- 新增 delegate 端点,支持后端通过代理节点转发请求而非传统 CONNECT 代理
- 注册时上报硬件信息(CPU/内存/fd_limit)和估算最大并发数
- 心跳上报活跃连接数,支持远程下发 node_name 配置
- HTTP 转发时剥离 X-Forwarded-* 等敏感头部
- 切换到 rustls-tls,降低日志级别减少噪音

后端:
- 从 http_client.py 提取代理相关逻辑至 proxy_node/resolver.py
- 从 routes.py 提取业务逻辑至 proxy_node/service.py
- handler 支持 delegate 模式(通过代理节点 HTTP 端点转发而非 CONNECT 隧道)
- ProxyNode 模型新增 hardware_info 和 estimated_max_concurrency 字段

前端:
- 新增 HardwareTooltip 组件展示节点硬件信息
- 远程配置支持下发 node_name
This commit is contained in:
fawney19
2026-02-08 13:33:08 +08:00
parent 254d30d32d
commit 519ad67eb1
44 changed files with 3339 additions and 1709 deletions

View File

@@ -6,9 +6,7 @@
from __future__ import annotations
import ipaddress
import uuid
from dataclasses import dataclass
from datetime import datetime, timezone
from typing import Any
from fastapi import APIRouter, Depends, Query, Request
@@ -18,51 +16,17 @@ from sqlalchemy.orm import Session
from src.api.base.admin_adapter import AdminApiAdapter
from src.api.base.context import ApiRequestContext
from src.api.base.pipeline import ApiRequestPipeline
from src.core.exceptions import InvalidRequestException, NotFoundException
from src.core.exceptions import InvalidRequestException
from src.database import get_db
from src.models.database import ProxyNode, ProxyNodeStatus, SystemConfig
from src.services.proxy_node.service import ProxyNodeService, node_to_dict
router = APIRouter(prefix="/api/admin/proxy-nodes", tags=["Admin - Proxy Nodes"])
pipeline = ApiRequestPipeline()
def _mask_password(password: str | None) -> str | None:
"""脱敏密码仅显示前2位和后2位长度不足 8 时全部遮蔽)"""
if not password:
return None
if len(password) < 8:
return "****"
return password[:2] + "****" + password[-2:]
def _node_to_dict(node: ProxyNode) -> dict[str, Any]:
d = {
"id": node.id,
"name": node.name,
"ip": node.ip,
"port": node.port,
"region": node.region,
"status": node.status.value if node.status else None,
"is_manual": bool(node.is_manual),
"registered_by": node.registered_by,
"last_heartbeat_at": node.last_heartbeat_at,
"heartbeat_interval": node.heartbeat_interval,
"active_connections": node.active_connections,
"total_requests": node.total_requests,
"avg_latency_ms": node.avg_latency_ms,
"tls_enabled": bool(node.tls_enabled),
"tls_cert_fingerprint": node.tls_cert_fingerprint,
"remote_config": node.remote_config,
"config_version": node.config_version,
"created_at": node.created_at,
"updated_at": node.updated_at,
}
# 手动节点附带代理配置(密码脱敏)
if node.is_manual:
d["proxy_url"] = node.proxy_url
d["proxy_username"] = node.proxy_username
d["proxy_password"] = _mask_password(node.proxy_password)
return d
# ---------------------------------------------------------------------------
# Pydantic 请求模型
# ---------------------------------------------------------------------------
class ProxyNodeRegisterRequest(BaseModel):
@@ -83,6 +47,10 @@ class ProxyNodeRegisterRequest(BaseModel):
None, max_length=128, description="TLS 证书 SHA-256 指纹"
)
# 硬件信息
hardware_info: dict | None = Field(None, description="硬件信息 JSON")
estimated_max_concurrency: int | None = Field(None, ge=0, description="估算最大并发连接数")
@field_validator("ip")
@classmethod
def validate_ip(cls, v: str) -> str:
@@ -110,6 +78,7 @@ class ProxyNodeUnregisterRequest(BaseModel):
class ProxyNodeRemoteConfigRequest(BaseModel):
"""管理端远程配置 — 通过心跳下发给 aether-proxy"""
node_name: str | None = Field(None, min_length=1, max_length=100, description="节点名称")
allowed_ports: list[int] | None = Field(None, description="允许代理的目标端口")
log_level: str | None = Field(None, description="日志级别 (trace/debug/info/warn/error)")
heartbeat_interval: int | None = Field(None, ge=5, le=600, description="心跳间隔(秒)")
@@ -186,6 +155,11 @@ class ManualProxyNodeUpdateRequest(BaseModel):
return v
# ---------------------------------------------------------------------------
# 路由端点
# ---------------------------------------------------------------------------
@router.post("/register")
async def register_proxy_node(request: Request, db: Session = Depends(get_db)) -> Any:
adapter = AdminRegisterProxyNodeAdapter()
@@ -250,6 +224,11 @@ async def update_proxy_node_config(
return await pipeline.run(adapter=adapter, http_request=request, db=db, mode=adapter.mode)
# ---------------------------------------------------------------------------
# 辅助函数
# ---------------------------------------------------------------------------
def _format_validation_error(exc: ValidationError) -> str:
parts: list[str] = []
for err in exc.errors():
@@ -259,6 +238,11 @@ def _format_validation_error(exc: ValidationError) -> str:
return "; ".join(parts) or "输入验证失败"
# ---------------------------------------------------------------------------
# Adapter 实现
# ---------------------------------------------------------------------------
@dataclass
class AdminRegisterProxyNodeAdapter(AdminApiAdapter):
name: str = "admin_register_proxy_node"
@@ -270,50 +254,22 @@ class AdminRegisterProxyNodeAdapter(AdminApiAdapter):
except ValidationError as exc:
raise InvalidRequestException("输入验证失败: " + _format_validation_error(exc))
now = datetime.now(timezone.utc)
node = (
context.db.query(ProxyNode)
.filter(ProxyNode.ip == req.ip, ProxyNode.port == req.port)
.first()
node = ProxyNodeService.register_node(
context.db,
name=req.name,
ip=req.ip,
port=req.port,
region=req.region,
heartbeat_interval=req.heartbeat_interval,
tls_enabled=req.tls_enabled,
tls_cert_fingerprint=req.tls_cert_fingerprint,
hardware_info=req.hardware_info,
estimated_max_concurrency=req.estimated_max_concurrency,
active_connections=req.active_connections,
total_requests=req.total_requests,
avg_latency_ms=req.avg_latency_ms,
registered_by=context.user.id if context.user else None,
)
if node:
node.name = req.name
node.region = req.region
node.status = ProxyNodeStatus.ONLINE
node.last_heartbeat_at = now
node.heartbeat_interval = req.heartbeat_interval
node.tls_enabled = req.tls_enabled
node.tls_cert_fingerprint = req.tls_cert_fingerprint
if req.active_connections is not None:
node.active_connections = req.active_connections
if req.total_requests is not None:
node.total_requests = req.total_requests
if req.avg_latency_ms is not None:
node.avg_latency_ms = req.avg_latency_ms
else:
node = ProxyNode(
id=str(uuid.uuid4()),
name=req.name,
ip=req.ip,
port=req.port,
region=req.region,
status=ProxyNodeStatus.ONLINE,
registered_by=context.user.id if context.user else None,
last_heartbeat_at=now,
heartbeat_interval=req.heartbeat_interval,
active_connections=req.active_connections or 0,
total_requests=req.total_requests or 0,
avg_latency_ms=req.avg_latency_ms,
tls_enabled=req.tls_enabled,
tls_cert_fingerprint=req.tls_cert_fingerprint,
created_at=now,
updated_at=now,
)
context.db.add(node)
context.db.commit()
context.db.refresh(node)
context.add_audit_metadata(
action="proxy_node_register",
@@ -322,7 +278,7 @@ class AdminRegisterProxyNodeAdapter(AdminApiAdapter):
proxy_node_port=node.port,
)
return {"node_id": node.id, "node": _node_to_dict(node)}
return {"node_id": node.id, "node": node_to_dict(node)}
@dataclass
@@ -336,31 +292,21 @@ class AdminHeartbeatProxyNodeAdapter(AdminApiAdapter):
except ValidationError as exc:
raise InvalidRequestException("输入验证失败: " + _format_validation_error(exc))
node = context.db.query(ProxyNode).filter(ProxyNode.id == req.node_id).first()
if not node:
raise NotFoundException(f"ProxyNode {req.node_id} 不存在", "proxy_node")
now = datetime.now(timezone.utc)
node.status = ProxyNodeStatus.ONLINE
node.last_heartbeat_at = now
if req.heartbeat_interval is not None:
node.heartbeat_interval = req.heartbeat_interval
if req.active_connections is not None:
node.active_connections = req.active_connections
if req.total_requests is not None:
node.total_requests = req.total_requests
if req.avg_latency_ms is not None:
node.avg_latency_ms = req.avg_latency_ms
context.db.commit()
context.db.refresh(node)
node = ProxyNodeService.heartbeat(
context.db,
node_id=req.node_id,
heartbeat_interval=req.heartbeat_interval,
active_connections=req.active_connections,
total_requests=req.total_requests,
avg_latency_ms=req.avg_latency_ms,
)
context.add_audit_metadata(
action="proxy_node_heartbeat",
proxy_node_id=node.id,
)
return {"message": "heartbeat ok", "node": _node_to_dict(node)}
return {"message": "heartbeat ok", "node": node_to_dict(node)}
@dataclass
@@ -374,13 +320,7 @@ class AdminUnregisterProxyNodeAdapter(AdminApiAdapter):
except ValidationError as exc:
raise InvalidRequestException("输入验证失败: " + _format_validation_error(exc))
node = context.db.query(ProxyNode).filter(ProxyNode.id == req.node_id).first()
if not node:
raise NotFoundException(f"ProxyNode {req.node_id} 不存在", "proxy_node")
node.status = ProxyNodeStatus.OFFLINE
node.updated_at = datetime.now(timezone.utc)
context.db.commit()
node = ProxyNodeService.unregister_node(context.db, node_id=req.node_id)
context.add_audit_metadata(
action="proxy_node_unregister",
@@ -398,20 +338,11 @@ class AdminListProxyNodesAdapter(AdminApiAdapter):
limit: int = 100
async def handle(self, context: ApiRequestContext) -> Any:
query = context.db.query(ProxyNode)
if self.status:
normalized = self.status.strip().lower()
allowed = {"online", "unhealthy", "offline"}
if normalized not in allowed:
raise InvalidRequestException(f"status 必须是以下之一: {sorted(allowed)}", "status")
query = query.filter(ProxyNode.status == ProxyNodeStatus(normalized))
total = query.count()
nodes = (
query.order_by(ProxyNode.updated_at.desc()).offset(self.skip).limit(self.limit).all()
nodes, total = ProxyNodeService.list_nodes(
context.db, status=self.status, skip=self.skip, limit=self.limit
)
return {
"items": [_node_to_dict(n) for n in nodes],
"items": [node_to_dict(n) for n in nodes],
"total": total,
"skip": self.skip,
"limit": self.limit,
@@ -424,93 +355,21 @@ class AdminDeleteProxyNodeAdapter(AdminApiAdapter):
node_id: str = ""
async def handle(self, context: ApiRequestContext) -> Any:
node = context.db.query(ProxyNode).filter(ProxyNode.id == self.node_id).first()
if not node:
raise NotFoundException(f"ProxyNode {self.node_id} 不存在", "proxy_node")
result = ProxyNodeService.delete_node(context.db, node_id=self.node_id)
context.add_audit_metadata(
action="proxy_node_delete",
proxy_node_id=node.id,
proxy_node_ip=node.ip,
proxy_node_port=node.port,
proxy_node_id=self.node_id,
**result.get("node_info", {}),
)
# 若该节点是系统默认代理,自动清除引用
was_system_proxy = False
sys_cfg = (
context.db.query(SystemConfig)
.filter(SystemConfig.key == "system_proxy_node_id")
.first()
)
if sys_cfg and sys_cfg.value == self.node_id:
sys_cfg.value = None
was_system_proxy = True
context.db.delete(node)
context.db.commit()
if was_system_proxy:
from src.clients.http_client import invalidate_system_proxy_cache
invalidate_system_proxy_cache()
msg = "deleted"
if was_system_proxy:
msg = "deleted, system default proxy cleared"
return {"message": msg, "node_id": self.node_id, "cleared_system_proxy": was_system_proxy}
def _parse_host_port(proxy_url: str) -> tuple[str, int]:
"""从代理 URL 中解析 host 和 port含协议前缀避免唯一约束冲突"""
from urllib.parse import urlparse
parsed = urlparse(proxy_url)
host = parsed.hostname or "manual"
default_ports = {"https": 443, "socks5": 1080}
port = parsed.port or default_ports.get((parsed.scheme or "").lower(), 80)
# 添加协议前缀区分同 host:port 不同协议的场景
scheme = (parsed.scheme or "http").lower()
if scheme != "http":
host = f"{scheme}://{host}"
return host, port
def _sanitize_proxy_error(err: Exception) -> str:
"""去除异常消息中可能包含的代理 URL 凭据(如 HMAC 签名)"""
import re
return re.sub(r"://[^@/]+@", "://***@", str(err))
def _build_test_proxy_url(node: ProxyNode) -> str:
"""为测试连通性构建代理 URL无需节点在线"""
if node.is_manual:
proxy_url = node.proxy_url
if not proxy_url:
raise InvalidRequestException("手动节点缺少 proxy_url")
if node.proxy_username:
from urllib.parse import quote, urlparse
parsed = urlparse(proxy_url)
encoded_username = quote(node.proxy_username, safe="")
encoded_password = quote(node.proxy_password, safe="") if node.proxy_password else ""
host_part = parsed.hostname or "localhost"
if parsed.port:
host_part = f"{host_part}:{parsed.port}"
if encoded_password:
proxy_url = f"{parsed.scheme}://{encoded_username}:{encoded_password}@{host_part}"
else:
proxy_url = f"{parsed.scheme}://{encoded_username}@{host_part}"
if parsed.path:
proxy_url += parsed.path
return proxy_url
else:
# aether-proxy: 使用 HMAC 认证构建代理 URL
from src.clients.http_client import _build_hmac_proxy_url
return _build_hmac_proxy_url(
node.ip, node.port, node.id, tls_enabled=bool(node.tls_enabled)
)
was_system_proxy = result["cleared_system_proxy"]
msg = "deleted, system default proxy cleared" if was_system_proxy else "deleted"
return {
"message": msg,
"node_id": self.node_id,
"cleared_system_proxy": was_system_proxy,
}
@dataclass
@@ -524,48 +383,22 @@ class AdminCreateManualProxyNodeAdapter(AdminApiAdapter):
except ValidationError as exc:
raise InvalidRequestException("输入验证失败: " + _format_validation_error(exc))
host, port = _parse_host_port(req.proxy_url)
now = datetime.now(timezone.utc)
node = ProxyNode(
id=str(uuid.uuid4()),
node = ProxyNodeService.create_manual_node(
context.db,
name=req.name,
ip=host,
port=port,
region=req.region,
is_manual=True,
proxy_url=req.proxy_url,
proxy_username=req.username,
proxy_password=req.password,
status=ProxyNodeStatus.ONLINE,
username=req.username,
password=req.password,
region=req.region,
registered_by=context.user.id if context.user else None,
last_heartbeat_at=None,
heartbeat_interval=0,
active_connections=0,
total_requests=0,
avg_latency_ms=None,
created_at=now,
updated_at=now,
)
# 检查是否已存在同地址的节点
existing = (
context.db.query(ProxyNode).filter(ProxyNode.ip == host, ProxyNode.port == port).first()
)
if existing:
raise InvalidRequestException(
f"已存在相同地址的代理节点: {existing.name} ({existing.ip}:{existing.port})"
)
context.db.add(node)
context.db.commit()
context.db.refresh(node)
context.add_audit_metadata(
action="proxy_node_manual_create",
proxy_node_id=node.id,
)
return {"node_id": node.id, "node": _node_to_dict(node)}
return {"node_id": node.id, "node": node_to_dict(node)}
@dataclass
@@ -574,53 +407,28 @@ class AdminUpdateManualProxyNodeAdapter(AdminApiAdapter):
node_id: str = ""
async def handle(self, context: ApiRequestContext) -> Any:
node = context.db.query(ProxyNode).filter(ProxyNode.id == self.node_id).first()
if not node:
raise NotFoundException(f"ProxyNode {self.node_id} 不存在", "proxy_node")
if not node.is_manual:
raise InvalidRequestException("只能编辑手动添加的代理节点")
payload = context.ensure_json_body()
try:
req = ManualProxyNodeUpdateRequest.model_validate(payload)
except ValidationError as exc:
raise InvalidRequestException("输入验证失败: " + _format_validation_error(exc))
if req.name is not None:
node.name = req.name
if req.proxy_url is not None:
node.proxy_url = req.proxy_url
host, port = _parse_host_port(req.proxy_url)
# 检查新地址是否与其他节点冲突
existing = (
context.db.query(ProxyNode)
.filter(ProxyNode.ip == host, ProxyNode.port == port, ProxyNode.id != node.id)
.first()
)
if existing:
raise InvalidRequestException(
f"已存在相同地址的代理节点: {existing.name} ({existing.ip}:{existing.port})"
)
node.ip = host
node.port = port
if req.username is not None:
node.proxy_username = req.username
# password: None=不发送(保留原值), ""=清空, 非空=更新
if req.password is not None:
node.proxy_password = req.password or None
if req.region is not None:
node.region = req.region
node.updated_at = datetime.now(timezone.utc)
context.db.commit()
context.db.refresh(node)
node = ProxyNodeService.update_manual_node(
context.db,
node_id=self.node_id,
name=req.name,
proxy_url=req.proxy_url,
username=req.username,
password=req.password,
region=req.region,
)
context.add_audit_metadata(
action="proxy_node_manual_update",
proxy_node_id=node.id,
)
return {"node_id": node.id, "node": _node_to_dict(node)}
return {"node_id": node.id, "node": node_to_dict(node)}
@dataclass
@@ -631,81 +439,7 @@ class AdminTestProxyNodeAdapter(AdminApiAdapter):
node_id: str = ""
async def handle(self, context: ApiRequestContext) -> Any:
import time as _time
import httpx
node = context.db.query(ProxyNode).filter(ProxyNode.id == self.node_id).first()
if not node:
raise NotFoundException(f"ProxyNode {self.node_id} 不存在", "proxy_node")
# 构建代理 URL
try:
proxy_url = _build_test_proxy_url(node)
except Exception as exc:
return {"success": False, "latency_ms": None, "exit_ip": None, "error": str(exc)}
test_url = "https://1.1.1.1/cdn-cgi/trace"
start = _time.monotonic()
# TLS 代理需要 proxy_ssl_context
from src.clients.http_client import _make_proxy_param
proxy_param = _make_proxy_param(proxy_url)
try:
async with httpx.AsyncClient(
proxy=proxy_param,
timeout=httpx.Timeout(15.0, connect=10.0),
) as client:
response = await client.get(test_url)
elapsed_ms = round((_time.monotonic() - start) * 1000, 1)
exit_ip = None
if response.status_code == 200:
for line in response.text.splitlines():
if line.startswith("ip="):
exit_ip = line.split("=", 1)[1].strip()
break
return {
"success": True,
"latency_ms": elapsed_ms,
"exit_ip": exit_ip,
"error": None,
}
except httpx.ProxyError as exc:
elapsed_ms = round((_time.monotonic() - start) * 1000, 1)
return {
"success": False,
"latency_ms": elapsed_ms,
"exit_ip": None,
"error": f"代理连接失败: {_sanitize_proxy_error(exc)}",
}
except httpx.ConnectError as exc:
elapsed_ms = round((_time.monotonic() - start) * 1000, 1)
return {
"success": False,
"latency_ms": elapsed_ms,
"exit_ip": None,
"error": f"连接失败: {_sanitize_proxy_error(exc)}",
}
except httpx.TimeoutException:
elapsed_ms = round((_time.monotonic() - start) * 1000, 1)
return {
"success": False,
"latency_ms": elapsed_ms,
"exit_ip": None,
"error": "连接超时15秒",
}
except Exception as exc:
elapsed_ms = round((_time.monotonic() - start) * 1000, 1)
return {
"success": False,
"latency_ms": elapsed_ms,
"exit_ip": None,
"error": _sanitize_proxy_error(exc),
}
return await ProxyNodeService.test_node(context.db, node_id=self.node_id)
@dataclass
@@ -716,12 +450,6 @@ class AdminUpdateProxyNodeConfigAdapter(AdminApiAdapter):
node_id: str = ""
async def handle(self, context: ApiRequestContext) -> Any:
node = context.db.query(ProxyNode).filter(ProxyNode.id == self.node_id).first()
if not node:
raise NotFoundException(f"ProxyNode {self.node_id} 不存在", "proxy_node")
if node.is_manual:
raise InvalidRequestException("手动节点不支持远程配置下发")
payload = context.ensure_json_body()
try:
req = ProxyNodeRemoteConfigRequest.model_validate(payload)
@@ -729,27 +457,21 @@ class AdminUpdateProxyNodeConfigAdapter(AdminApiAdapter):
raise InvalidRequestException("输入验证失败: " + _format_validation_error(exc))
# Build config dict with only the supplied fields
config: dict[str, Any] = {}
config_updates: dict[str, Any] = {}
if req.node_name is not None:
config_updates["node_name"] = req.node_name
if req.allowed_ports is not None:
config["allowed_ports"] = req.allowed_ports
config_updates["allowed_ports"] = req.allowed_ports
if req.log_level is not None:
config["log_level"] = req.log_level
config_updates["log_level"] = req.log_level
if req.heartbeat_interval is not None:
config["heartbeat_interval"] = req.heartbeat_interval
config_updates["heartbeat_interval"] = req.heartbeat_interval
if req.timestamp_tolerance is not None:
config["timestamp_tolerance"] = req.timestamp_tolerance
config_updates["timestamp_tolerance"] = req.timestamp_tolerance
# Merge with existing config (so partial updates are preserved)
# Copy to a new dict so SQLAlchemy detects the change on the JSON column
existing = dict(node.remote_config) if node.remote_config else {}
existing.update(config)
node.remote_config = existing
node.config_version = (node.config_version or 0) + 1
node.updated_at = datetime.now(timezone.utc)
context.db.commit()
context.db.refresh(node)
node = ProxyNodeService.update_node_config(
context.db, node_id=self.node_id, config_updates=config_updates
)
context.add_audit_metadata(
action="proxy_node_config_update",
@@ -761,5 +483,5 @@ class AdminUpdateProxyNodeConfigAdapter(AdminApiAdapter):
"node_id": node.id,
"config_version": node.config_version,
"remote_config": node.remote_config,
"node": _node_to_dict(node),
"node": node_to_dict(node),
}

View File

@@ -914,7 +914,7 @@ class ChatHandlerBase(BaseMessageHandler, ABC):
ctx.selected_base_url = envelope.capture_selected_base_url() if envelope else None
# 记录代理信息
from src.clients.http_client import get_proxy_label, resolve_proxy_info
from src.services.proxy_node.resolver import get_proxy_label, resolve_proxy_info
ctx.proxy_info = resolve_proxy_info(provider.proxy)
proxy_label = get_proxy_label(ctx.proxy_info)
@@ -928,19 +928,23 @@ class ChatHandlerBase(BaseMessageHandler, ABC):
# simulate streaming to the client (sync -> stream bridge).
if not upstream_is_stream:
from src.clients.http_client import HTTPClientPool
from src.services.proxy_node.resolver import build_post_kwargs, resolve_delegate_config
request_timeout_sync = provider.request_timeout or config.http_request_timeout
http_client = await HTTPClientPool.get_proxy_client(
proxy_config=provider.proxy,
delegate_cfg = resolve_delegate_config(provider.proxy)
http_client = await HTTPClientPool.get_upstream_client(
delegate_cfg, proxy_config=provider.proxy
)
try:
resp = await http_client.post(
url,
json=provider_payload,
_pkw = build_post_kwargs(
delegate_cfg,
url=url,
headers=provider_headers,
timeout=httpx.Timeout(request_timeout_sync),
payload=provider_payload,
timeout=request_timeout_sync,
)
resp = await http_client.post(**_pkw)
except (httpx.ConnectError, httpx.ConnectTimeout, httpx.TimeoutException) as e:
if envelope:
envelope.on_connection_error(base_url=ctx.selected_base_url, exc=e)
@@ -971,12 +975,15 @@ class ChatHandlerBase(BaseMessageHandler, ABC):
ctx.provider_request_headers = provider_headers
# retry once
resp = await http_client.post(
url,
json=provider_payload,
_pkw = build_post_kwargs(
delegate_cfg,
url=url,
headers=provider_headers,
timeout=httpx.Timeout(request_timeout_sync),
payload=provider_payload,
timeout=request_timeout_sync,
refresh_auth=True,
)
resp = await http_client.post(**_pkw)
ctx.status_code = resp.status_code
ctx.response_headers = dict(resp.headers)
if envelope:
@@ -1120,10 +1127,11 @@ class ChatHandlerBase(BaseMessageHandler, ABC):
# 创建 HTTP 客户端(支持代理配置,从 Provider 读取)
from src.clients.http_client import HTTPClientPool
from src.services.proxy_node.resolver import build_stream_kwargs, resolve_delegate_config
http_client = HTTPClientPool.create_client_with_proxy(
proxy_config=provider.proxy,
timeout=timeout_config,
delegate_cfg = resolve_delegate_config(provider.proxy)
http_client = HTTPClientPool.create_upstream_stream_client(
delegate_cfg, proxy_config=provider.proxy, timeout=timeout_config
)
# 用于存储内部函数的结果(必须在函数定义前声明,供 nonlocal 使用)
@@ -1134,9 +1142,18 @@ class ChatHandlerBase(BaseMessageHandler, ABC):
async def _connect_and_prefetch() -> None:
"""建立连接并预读首字节(受整体超时控制)"""
nonlocal byte_iterator, prefetched_chunks, response_ctx
response_ctx = http_client.stream(
"POST", url, json=provider_payload, headers=provider_headers
_skw = build_stream_kwargs(
delegate_cfg,
url=url,
headers=provider_headers,
payload=provider_payload,
timeout=(
provider.request_timeout or config.http_request_timeout
if delegate_cfg
else None
),
)
response_ctx = http_client.stream(**_skw)
stream_response = await response_ctx.__aenter__()
ctx.status_code = stream_response.status_code
@@ -1547,7 +1564,7 @@ class ChatHandlerBase(BaseMessageHandler, ABC):
selected_base_url_cached = envelope.capture_selected_base_url() if envelope else None
# 记录代理信息
from src.clients.http_client import get_proxy_label, resolve_proxy_info
from src.services.proxy_node.resolver import get_proxy_label, resolve_proxy_info
sync_proxy_info = resolve_proxy_info(provider.proxy)
_proxy_label = get_proxy_label(sync_proxy_info)
@@ -1562,12 +1579,19 @@ class ChatHandlerBase(BaseMessageHandler, ABC):
# 获取复用的 HTTP 客户端(支持代理配置,从 Provider 读取)
# 注意:使用 get_proxy_client 复用连接池,不再每次创建新客户端
from src.clients.http_client import HTTPClientPool
from src.services.proxy_node.resolver import (
build_post_kwargs,
build_stream_kwargs,
resolve_delegate_config,
)
# 非流式请求使用 http_request_timeout 作为整体超时
# 优先使用 Provider 配置,否则使用全局配置
request_timeout = provider.request_timeout or config.http_request_timeout
http_client = await HTTPClientPool.get_proxy_client(
proxy_config=provider.proxy,
delegate_cfg = resolve_delegate_config(provider.proxy)
http_client = await HTTPClientPool.get_upstream_client(
delegate_cfg, proxy_config=provider.proxy
)
# 注意:不使用 async with因为复用的客户端不应该被关闭
@@ -1575,12 +1599,14 @@ class ChatHandlerBase(BaseMessageHandler, ABC):
resp: httpx.Response | None = None
if not upstream_is_stream:
try:
resp = await http_client.post(
url,
json=provider_payload,
_pkw = build_post_kwargs(
delegate_cfg,
url=url,
headers=provider_hdrs,
timeout=httpx.Timeout(request_timeout),
payload=provider_payload,
timeout=request_timeout,
)
resp = await http_client.post(**_pkw)
except (httpx.ConnectError, httpx.ConnectTimeout, httpx.TimeoutException) as e:
if envelope:
envelope.on_connection_error(base_url=selected_base_url_cached, exc=e)
@@ -1596,13 +1622,14 @@ class ChatHandlerBase(BaseMessageHandler, ABC):
)
try:
async with http_client.stream(
"POST",
url,
json=provider_payload,
_stream_args = build_stream_kwargs(
delegate_cfg,
url=url,
headers=provider_hdrs,
timeout=httpx.Timeout(request_timeout),
) as stream_resp:
payload=provider_payload,
timeout=request_timeout,
)
async with http_client.stream(**_stream_args) as stream_resp:
resp = stream_resp
status_code = stream_resp.status_code

View File

@@ -891,8 +891,8 @@ class CliMessageHandlerBase(BaseMessageHandler):
ctx.selected_base_url = envelope.capture_selected_base_url() if envelope else None
# 记录代理信息sync-bridge 路径,早于流式路径执行)
from src.clients.http_client import get_proxy_label as _gpl
from src.clients.http_client import resolve_proxy_info as _rpi
from src.services.proxy_node.resolver import get_proxy_label as _gpl
from src.services.proxy_node.resolver import resolve_proxy_info as _rpi
ctx.proxy_info = _rpi(provider.proxy)
@@ -900,19 +900,23 @@ class CliMessageHandlerBase(BaseMessageHandler):
# simulate streaming to the client (sync -> stream bridge).
if not upstream_is_stream:
from src.clients.http_client import HTTPClientPool
from src.services.proxy_node.resolver import build_post_kwargs, resolve_delegate_config
request_timeout_sync = provider.request_timeout or config.http_request_timeout
http_client = await HTTPClientPool.get_proxy_client(
proxy_config=provider.proxy,
delegate_cfg = resolve_delegate_config(provider.proxy)
http_client = await HTTPClientPool.get_upstream_client(
delegate_cfg, proxy_config=provider.proxy
)
try:
resp = await http_client.post(
url,
json=provider_payload,
_pkw = build_post_kwargs(
delegate_cfg,
url=url,
headers=provider_headers,
timeout=httpx.Timeout(request_timeout_sync),
payload=provider_payload,
timeout=request_timeout_sync,
)
resp = await http_client.post(**_pkw)
except (httpx.ConnectError, httpx.ConnectTimeout, httpx.TimeoutException) as e:
if envelope:
envelope.on_connection_error(base_url=ctx.selected_base_url, exc=e)
@@ -943,12 +947,15 @@ class CliMessageHandlerBase(BaseMessageHandler):
ctx.provider_request_headers = provider_headers
# retry once
resp = await http_client.post(
url,
json=provider_payload,
_pkw = build_post_kwargs(
delegate_cfg,
url=url,
headers=provider_headers,
timeout=httpx.Timeout(request_timeout_sync),
payload=provider_payload,
timeout=request_timeout_sync,
refresh_auth=True,
)
resp = await http_client.post(**_pkw)
ctx.status_code = resp.status_code
ctx.response_headers = dict(resp.headers)
if envelope:
@@ -1091,10 +1098,11 @@ class CliMessageHandlerBase(BaseMessageHandler):
# 创建 HTTP 客户端(支持代理配置,从 Provider 读取)
from src.clients.http_client import HTTPClientPool
from src.services.proxy_node.resolver import build_stream_kwargs, resolve_delegate_config
http_client = HTTPClientPool.create_client_with_proxy(
proxy_config=provider.proxy,
timeout=timeout_config,
delegate_cfg = resolve_delegate_config(provider.proxy)
http_client = HTTPClientPool.create_upstream_stream_client(
delegate_cfg, proxy_config=provider.proxy, timeout=timeout_config
)
# 用于存储内部函数的结果(必须在函数定义前声明,供 nonlocal 使用)
@@ -1105,9 +1113,18 @@ class CliMessageHandlerBase(BaseMessageHandler):
async def _connect_and_prefetch() -> None:
"""建立连接并预读首字节(受整体超时控制)"""
nonlocal byte_iterator, prefetched_chunks, response_ctx
response_ctx = http_client.stream(
"POST", url, json=provider_payload, headers=provider_headers
_skw = build_stream_kwargs(
delegate_cfg,
url=url,
headers=provider_headers,
payload=provider_payload,
timeout=(
provider.request_timeout or config.http_request_timeout
if delegate_cfg
else None
),
)
response_ctx = http_client.stream(**_skw)
stream_response = await response_ctx.__aenter__()
ctx.status_code = stream_response.status_code
@@ -2962,7 +2979,7 @@ class CliMessageHandlerBase(BaseMessageHandler):
selected_base_url_cached = envelope.capture_selected_base_url() if envelope else None
# 记录代理信息
from src.clients.http_client import get_proxy_label, resolve_proxy_info
from src.services.proxy_node.resolver import get_proxy_label, resolve_proxy_info
sync_proxy_info = resolve_proxy_info(provider.proxy)
_proxy_label = get_proxy_label(sync_proxy_info)
@@ -2978,12 +2995,19 @@ class CliMessageHandlerBase(BaseMessageHandler):
# 获取复用的 HTTP 客户端(支持代理配置,从 Provider 读取)
# 注意:使用 get_proxy_client 复用连接池,不再每次创建新客户端
from src.clients.http_client import HTTPClientPool
from src.services.proxy_node.resolver import (
build_post_kwargs,
build_stream_kwargs,
resolve_delegate_config,
)
# 非流式请求使用 http_request_timeout 作为整体超时
# 优先使用 Provider 配置,否则使用全局配置
request_timeout = provider.request_timeout or config.http_request_timeout
http_client = await HTTPClientPool.get_proxy_client(
proxy_config=provider.proxy,
delegate_cfg = resolve_delegate_config(provider.proxy)
http_client = await HTTPClientPool.get_upstream_client(
delegate_cfg, proxy_config=provider.proxy
)
# 注意:不使用 async with因为复用的客户端不应该被关闭
@@ -2991,12 +3015,14 @@ class CliMessageHandlerBase(BaseMessageHandler):
resp: httpx.Response | None = None
if not upstream_is_stream:
try:
resp = await http_client.post(
url,
json=provider_payload,
_pkw = build_post_kwargs(
delegate_cfg,
url=url,
headers=provider_headers,
timeout=httpx.Timeout(request_timeout),
payload=provider_payload,
timeout=request_timeout,
)
resp = await http_client.post(**_pkw)
except (httpx.ConnectError, httpx.ConnectTimeout, httpx.TimeoutException) as e:
if envelope:
envelope.on_connection_error(base_url=selected_base_url_cached, exc=e)
@@ -3013,13 +3039,14 @@ class CliMessageHandlerBase(BaseMessageHandler):
)
try:
async with http_client.stream(
"POST",
url,
json=provider_payload,
_stream_args = build_stream_kwargs(
delegate_cfg,
url=url,
headers=provider_headers,
timeout=httpx.Timeout(request_timeout),
) as stream_resp:
payload=provider_payload,
timeout=request_timeout,
)
async with http_client.stream(**_stream_args) as stream_resp:
resp = stream_resp
status_code = stream_resp.status_code

View File

@@ -11,398 +11,26 @@
from __future__ import annotations
import asyncio
import hashlib
import hmac
import time
from contextlib import asynccontextmanager
from typing import Any
from urllib.parse import quote, urlparse
import httpx
from src.config import config
from src.core.exceptions import ProxyNodeUnavailableError
from src.core.logger import logger
from src.services.proxy_node.resolver import (
build_proxy_url,
compute_proxy_cache_key,
get_system_proxy_config,
make_proxy_param,
)
from src.utils.ssl_utils import get_ssl_context
# 模块级锁,避免类属性延迟初始化的竞态条件
_proxy_clients_lock = asyncio.Lock()
_default_client_lock = asyncio.Lock()
# ProxyNode 信息缓存(降低高频 DB 查询开销)
_proxy_node_cache: dict[str, tuple[dict[str, Any] | None, float]] = {}
_PROXY_NODE_CACHE_TTL_SECONDS = 60.0
_PROXY_NODE_CACHE_MAX_SIZE = 256
def _get_proxy_node_info(node_id: str) -> dict[str, Any] | None:
"""
读取 ProxyNode 信息(带内存 TTL 缓存)
Returns:
aether-proxy 节点: {"ip": str, "port": int, "name": str, ...}
手动节点: {"is_manual": True, "name": str, "proxy_url": str, ...}
不存在/非在线: None
"""
now = time.time()
cached = _proxy_node_cache.get(node_id)
if cached:
value, expires_at = cached
if now < expires_at:
return value
# 防止无效 node_id 导致缓存无限膨胀
if len(_proxy_node_cache) >= _PROXY_NODE_CACHE_MAX_SIZE:
_proxy_node_cache.clear()
from src.database import create_session
from src.models.database import ProxyNode, ProxyNodeStatus
db = create_session()
try:
node = db.query(ProxyNode).filter(ProxyNode.id == node_id).first()
if not node or node.status != ProxyNodeStatus.ONLINE:
_proxy_node_cache[node_id] = (None, now + _PROXY_NODE_CACHE_TTL_SECONDS)
return None
if node.is_manual:
value: dict[str, Any] = {
"is_manual": True,
"name": node.name,
"proxy_url": node.proxy_url,
"username": node.proxy_username,
"password": node.proxy_password,
}
else:
value = {
"name": node.name,
"ip": node.ip,
"port": node.port,
"tls_enabled": bool(node.tls_enabled),
"tls_cert_fingerprint": node.tls_cert_fingerprint,
}
_proxy_node_cache[node_id] = (value, now + _PROXY_NODE_CACHE_TTL_SECONDS)
return value
finally:
db.close()
def _build_hmac_proxy_url(ip: str, port: int, node_id: str, *, tls_enabled: bool = False) -> str:
"""
构建带 HMAC BasicAuth 的 httpx proxy URL
格式: http(s)://hmac:{timestamp}.{signature}@{ip}:{port}
signature = HMAC-SHA256(PROXY_HMAC_KEY, "{timestamp}\\n{node_id}") 的 hex
当 tls_enabled=True 时使用 https:// scheme。
"""
if not config.proxy_hmac_key:
logger.error("PROXY_HMAC_KEY 未配置,无法使用 ProxyNode 代理 (node_id={})", node_id)
raise ProxyNodeUnavailableError(
"PROXY_HMAC_KEY 未配置,无法使用 ProxyNode 代理", node_id=node_id
)
timestamp = str(int(time.time()))
payload = f"{timestamp}\n{node_id}".encode("utf-8")
signature = hmac.new(
config.proxy_hmac_key.encode("utf-8"),
payload,
hashlib.sha256,
).hexdigest()
host = f"[{ip}]" if ":" in ip else ip
scheme = "https" if tls_enabled else "http"
return f"{scheme}://hmac:{timestamp}.{signature}@{host}:{int(port)}"
# 系统默认代理缓存
_system_proxy_cache: tuple[dict[str, Any] | None, float] | None = None
_SYSTEM_PROXY_CACHE_TTL = 60.0
def invalidate_system_proxy_cache() -> None:
"""手动失效系统代理缓存(在删除节点等操作后调用)"""
global _system_proxy_cache
_system_proxy_cache = None
def get_system_proxy_config() -> dict[str, Any] | None:
"""
获取系统默认代理配置(带 TTL 缓存)
从 system_configs 表中读取 system_proxy_node_id。
返回 {"node_id": "...", "enabled": True} 或 None。
"""
global _system_proxy_cache
now = time.time()
if _system_proxy_cache:
value, expires_at = _system_proxy_cache
if now < expires_at:
return value
from src.database import create_session
from src.services.system.config import SystemConfigService
db = create_session()
try:
node_id = SystemConfigService.get_config(db, "system_proxy_node_id")
if node_id and isinstance(node_id, str) and node_id.strip():
result: dict[str, Any] | None = {"node_id": node_id.strip(), "enabled": True}
else:
result = None
_system_proxy_cache = (result, now + _SYSTEM_PROXY_CACHE_TTL)
return result
except Exception as exc:
logger.warning("获取系统默认代理配置失败: {}", exc)
_system_proxy_cache = (None, now + _SYSTEM_PROXY_CACHE_TTL)
return None
finally:
db.close()
def resolve_ops_proxy(
connector_config: dict[str, Any] | None,
) -> str | httpx.Proxy | None:
"""
从 ops connector.config 中解析代理参数(含系统默认回退)
优先级:
1. connector_config.proxy_node_id新格式
2. connector_config.proxy旧格式 URL 字符串)
3. 系统默认代理节点
Args:
connector_config: connector 的 config 字典
Returns:
httpx 可接受的代理参数str 或 httpx.Proxy或 None
"""
if connector_config:
# 新格式proxy_node_id → 通过 build_proxy_url 解析
node_id = connector_config.get("proxy_node_id")
if isinstance(node_id, str) and node_id.strip():
try:
url = build_proxy_url({"node_id": node_id.strip(), "enabled": True})
return _make_proxy_param(url)
except Exception as exc:
logger.warning("解析 proxy_node_id={} 失败,回退到直连: {}", node_id, exc)
return None
# 旧格式:直接返回 proxy URL 字符串
proxy = connector_config.get("proxy")
if isinstance(proxy, str) and proxy.strip():
return proxy
# 回退:系统默认代理
system_proxy = get_system_proxy_config()
if system_proxy:
try:
url = build_proxy_url(system_proxy)
return _make_proxy_param(url)
except Exception as exc:
logger.warning("构建系统默认代理 URL 失败: {}", exc)
return None
return None
def _compute_proxy_cache_key(proxy_config: dict[str, Any] | None) -> str:
"""
计算代理配置的缓存键
Args:
proxy_config: 代理配置字典
Returns:
缓存键字符串,无代理时返回 "__no_proxy__"
"""
if not proxy_config:
return "__no_proxy__"
# enabled=False 时视为无代理(兼容旧数据)
if not proxy_config.get("enabled", True):
return "__no_proxy__"
# ProxyNode 模式:基于 node_id + 时间桶缓存,避免签名随时间变化导致 cache key 爆炸
node_id = proxy_config.get("node_id")
if isinstance(node_id, str) and node_id.strip():
time_bucket = int(time.time() / 120) # 120 秒一个桶
return f"proxy_node:{node_id.strip()}:{time_bucket}"
# 构建代理 URL 作为缓存键的基础
proxy_url = build_proxy_url(proxy_config)
if not proxy_url:
return "__no_proxy__"
# 使用 MD5 哈希来避免过长的键名
return f"proxy:{hashlib.md5(proxy_url.encode()).hexdigest()[:16]}"
def build_proxy_url(proxy_config: dict[str, Any]) -> str | None:
"""
根据代理配置构建完整的代理 URL
Args:
proxy_config: 代理配置字典,支持两种模式:
- 手动 URL 模式: {url, username, password, enabled}
- ProxyNode 模式: {node_id, enabled}
Returns:
完整的代理 URL如 socks5://user:pass@host:port
如果 enabled=False 或无配置,返回 None
"""
if not proxy_config:
return None
# 检查 enabled 字段,默认为 True兼容旧数据
if not proxy_config.get("enabled", True):
return None
# ProxyNode 模式aether-proxy 或手动节点)
node_id = proxy_config.get("node_id")
if isinstance(node_id, str) and node_id.strip():
node_id = node_id.strip()
node_info = _get_proxy_node_info(node_id)
if not node_info:
logger.warning("代理节点不可用(离线或不存在): node_id={}", node_id)
raise ProxyNodeUnavailableError(f"代理节点 {node_id} 不可用", node_id=node_id)
# 手动节点:直接使用存储的代理 URL含认证信息
if node_info.get("is_manual"):
manual_url = node_info.get("proxy_url")
if not manual_url:
raise ProxyNodeUnavailableError(
f"手动代理节点 {node_id} 缺少 proxy_url", node_id=node_id
)
username = node_info.get("username")
password = node_info.get("password")
if username:
parsed = urlparse(manual_url)
encoded_username = quote(username, safe="")
encoded_password = quote(password, safe="") if password else ""
# 使用 hostname+port 而非 netloc避免 URL 内嵌凭据导致双重认证
host_part = parsed.hostname or "localhost"
if parsed.port:
host_part = f"{host_part}:{parsed.port}"
if encoded_password:
auth_url = (
f"{parsed.scheme}://{encoded_username}:{encoded_password}@{host_part}"
)
else:
auth_url = f"{parsed.scheme}://{encoded_username}@{host_part}"
if parsed.path:
auth_url += parsed.path
return auth_url
return manual_url
# aether-proxy 节点:使用 HMAC 认证
return _build_hmac_proxy_url(
node_info["ip"],
node_info["port"],
node_id,
tls_enabled=node_info.get("tls_enabled", False),
)
proxy_url: str | None = proxy_config.get("url")
if not proxy_url:
return None
username = proxy_config.get("username")
password = proxy_config.get("password")
# 只要有用户名就添加认证信息(密码可以为空)
if username:
parsed = urlparse(proxy_url)
# URL 编码用户名和密码,处理特殊字符(如 @, :, /
encoded_username = quote(username, safe="")
encoded_password = quote(password, safe="") if password else ""
# 重新构建带认证的代理 URL
if encoded_password:
auth_proxy = f"{parsed.scheme}://{encoded_username}:{encoded_password}@{parsed.netloc}"
else:
auth_proxy = f"{parsed.scheme}://{encoded_username}@{parsed.netloc}"
if parsed.path:
auth_proxy += parsed.path
return auth_proxy
return proxy_url
def resolve_proxy_info(proxy_config: dict[str, Any] | None) -> dict[str, Any] | None:
"""
解析代理配置的摘要信息(用于日志和 usage 记录)
不构建实际的代理 URL仅返回可读的代理标识信息。
Returns:
{"node_id": "xxx", "node_name": "proxy-01", "source": "provider"} 或
{"url": "socks5://host:port", "source": "provider"} 或
{"node_id": "xxx", "node_name": "...", "source": "system"} 或
None (直连)
"""
source = "provider"
effective_config = proxy_config
# 无 provider 级代理时,尝试系统默认代理
if not effective_config or not effective_config.get("enabled", True):
effective_config = get_system_proxy_config()
source = "system"
if not effective_config or not effective_config.get("enabled", True):
return None
# ProxyNode 模式
node_id = effective_config.get("node_id")
if isinstance(node_id, str) and node_id.strip():
node_id = node_id.strip()
node_info = _get_proxy_node_info(node_id)
node_name = node_info.get("name", "unknown") if node_info else "offline"
return {"node_id": node_id, "node_name": node_name, "source": source}
# 旧格式 URL 模式
proxy_url = effective_config.get("url")
if proxy_url:
# 脱敏:只保留 scheme + host + port
try:
parsed = urlparse(proxy_url)
host_part = parsed.hostname or "unknown"
if parsed.port:
host_part = f"{host_part}:{parsed.port}"
safe_url = f"{parsed.scheme}://{host_part}"
except Exception:
safe_url = "unknown"
return {"url": safe_url, "source": source}
return None
def get_proxy_label(proxy_info: dict[str, Any] | None) -> str:
"""从 proxy_info 中提取简短的代理标签(用于日志)"""
if not proxy_info:
return "direct"
return proxy_info.get("node_name") or proxy_info.get("url") or "unknown"
def _make_proxy_param(proxy_url: str | None) -> str | httpx.Proxy | None:
"""
根据代理 URL 返回 httpx 可接受的 proxy 参数。
对于 https:// scheme 的代理 URLTLS aether-proxy 节点),返回 httpx.Proxy
并附带 proxy_ssl_contextCERT_NONE因为使用自签名证书
其他情况返回普通 URL 字符串。
"""
if not proxy_url:
return None
# https:// 代理需要 ssl_context自签名证书场景
if proxy_url.startswith("https://"):
from src.utils.ssl_utils import get_proxy_ssl_context
return httpx.Proxy(url=proxy_url, ssl_context=get_proxy_ssl_context())
return proxy_url
class HTTPClientPool:
"""
@@ -423,6 +51,8 @@ class HTTPClientPool:
_proxy_clients: dict[str, tuple[httpx.AsyncClient, float]] = {}
# 代理客户端缓存上限(避免内存泄漏)
_max_proxy_clients: int = 50
# 代发客户端缓存:{tls: client, plain: client}
_delegate_clients: dict[str, httpx.AsyncClient] = {}
def __new__(cls) -> "HTTPClientPool":
if cls._instance is None:
@@ -459,10 +89,10 @@ class HTTPClientPool:
follow_redirects=True, # 跟随重定向
)
logger.info(
f"全局HTTP客户端池已初始化: "
f"max_connections={config.http_max_connections}, "
f"keepalive={config.http_keepalive_connections}, "
f"keepalive_expiry={config.http_keepalive_expiry}s"
"全局HTTP客户端池已初始化: max_connections={}, keepalive={}, keepalive_expiry={}s",
config.http_max_connections,
config.http_keepalive_connections,
config.http_keepalive_expiry,
)
return cls._default_client
@@ -492,10 +122,10 @@ class HTTPClientPool:
follow_redirects=True, # 跟随重定向
)
logger.info(
f"全局HTTP客户端池已初始化: "
f"max_connections={config.http_max_connections}, "
f"keepalive={config.http_keepalive_connections}, "
f"keepalive_expiry={config.http_keepalive_expiry}s"
"全局HTTP客户端池已初始化: max_connections={}, keepalive={}, keepalive_expiry={}s",
config.http_max_connections,
config.http_keepalive_connections,
config.http_keepalive_expiry,
)
return cls._default_client
@@ -526,7 +156,7 @@ class HTTPClientPool:
default_config.update(kwargs)
cls._clients[name] = httpx.AsyncClient(**default_config) # type: ignore[arg-type]
logger.debug(f"创建命名HTTP客户端: {name}")
logger.debug("创建命名HTTP客户端: {}", name)
return cls._clients[name]
@@ -548,9 +178,9 @@ class HTTPClientPool:
# 异步关闭旧客户端
try:
await old_client.aclose()
logger.debug(f"淘汰代理客户端: {oldest_key}")
logger.debug("淘汰代理客户端: {}", oldest_key)
except Exception as e:
logger.warning(f"关闭代理客户端失败: {e}")
logger.warning("关闭代理客户端失败: {}", e)
@classmethod
async def get_proxy_client(
@@ -574,7 +204,7 @@ class HTTPClientPool:
if not proxy_config:
proxy_config = get_system_proxy_config()
cache_key = _compute_proxy_cache_key(proxy_config)
cache_key = compute_proxy_cache_key(proxy_config)
# 无代理时返回默认客户端
if cache_key == "__no_proxy__":
@@ -588,7 +218,7 @@ class HTTPClientPool:
# 健康检查:如果客户端已关闭,移除并重新创建
if client.is_closed:
del cls._proxy_clients[cache_key]
logger.debug(f"代理客户端已关闭,将重新创建: {cache_key}")
logger.debug("代理客户端已关闭,将重新创建: {}", cache_key)
else:
# 更新最后使用时间
cls._proxy_clients[cache_key] = (client, time.time())
@@ -617,7 +247,7 @@ class HTTPClientPool:
# 添加代理配置
proxy_url = build_proxy_url(proxy_config) if proxy_config else None
proxy_param = _make_proxy_param(proxy_url)
proxy_param = make_proxy_param(proxy_url)
if proxy_param:
client_config["proxy"] = proxy_param
@@ -630,7 +260,7 @@ class HTTPClientPool:
proxy_config.get("node_id") or proxy_config.get("url") or "unknown"
)
logger.debug(
f"创建代理客户端(缓存): {proxy_label}, " f"缓存数量: {len(cls._proxy_clients)}"
"创建代理客户端(缓存): {}, 缓存数量: {}", proxy_label, len(cls._proxy_clients)
)
return client
@@ -645,7 +275,7 @@ class HTTPClientPool:
for name, client in cls._clients.items():
await client.aclose()
logger.debug(f"命名HTTP客户端已关闭: {name}")
logger.debug("命名HTTP客户端已关闭: {}", name)
cls._clients.clear()
@@ -653,11 +283,21 @@ class HTTPClientPool:
for cache_key, (client, _) in cls._proxy_clients.items():
try:
await client.aclose()
logger.debug(f"代理客户端已关闭: {cache_key}")
logger.debug("代理客户端已关闭: {}", cache_key)
except Exception as e:
logger.warning(f"关闭代理客户端失败: {e}")
logger.warning("关闭代理客户端失败: {}", e)
cls._proxy_clients.clear()
# 关闭代发客户端缓存
for cache_key, client in cls._delegate_clients.items():
try:
await client.aclose()
logger.debug("代发客户端已关闭: {}", cache_key)
except Exception as e:
logger.warning("关闭代发客户端失败: {}", e)
cls._delegate_clients.clear()
logger.info("所有HTTP客户端已关闭")
@classmethod
@@ -732,14 +372,128 @@ class HTTPClientPool:
# 添加代理配置
proxy_url = build_proxy_url(proxy_config) if proxy_config else None
proxy_param = _make_proxy_param(proxy_url)
proxy_param = make_proxy_param(proxy_url)
if proxy_param:
client_config["proxy"] = proxy_param
logger.debug(f"创建带代理的HTTP客户端(一次性): {proxy_config.get('url', 'unknown')}")
logger.debug("创建带代理的HTTP客户端(一次性): {}", proxy_config.get("url", "unknown"))
client_config.update(kwargs)
return httpx.AsyncClient(**client_config) # type: ignore[arg-type]
@classmethod
def create_delegate_stream_client(
cls,
delegate_config: dict[str, Any],
timeout: httpx.Timeout | None = None,
) -> httpx.AsyncClient:
"""
创建用于代发流式请求的 httpx 客户端
代发模式下不配置 proxy直接 POST 到 proxy 的 /_aether/delegate 端点。
调用者需要负责关闭返回的客户端。
"""
client_config: dict[str, Any] = {
"http2": False,
"follow_redirects": False,
}
if timeout:
client_config["timeout"] = timeout
else:
client_config["timeout"] = httpx.Timeout(
connect=config.http_connect_timeout,
read=config.http_read_timeout,
write=config.http_write_timeout,
pool=config.http_pool_timeout,
)
if delegate_config.get("tls_enabled"):
from src.utils.ssl_utils import get_proxy_ssl_context
client_config["verify"] = get_proxy_ssl_context()
else:
client_config["verify"] = get_ssl_context()
return httpx.AsyncClient(**client_config)
@classmethod
async def get_delegate_client(
cls,
delegate_config: dict[str, Any],
) -> httpx.AsyncClient:
"""
获取可复用的代发客户端(非流式请求用)
根据 TLS 状态缓存两个客户端tls / plain避免每次请求创建新客户端。
当 tls_enabled=True 时使用 get_proxy_ssl_context()(信任自签名证书)。
"""
cache_key = "tls" if delegate_config.get("tls_enabled") else "plain"
lock = cls._get_proxy_clients_lock()
async with lock:
existing = cls._delegate_clients.get(cache_key)
if existing and not existing.is_closed:
return existing
if cache_key == "tls":
from src.utils.ssl_utils import get_proxy_ssl_context
verify: Any = get_proxy_ssl_context()
else:
verify = get_ssl_context()
client = httpx.AsyncClient(
http2=False,
verify=verify,
follow_redirects=False,
timeout=httpx.Timeout(
connect=config.http_connect_timeout,
read=config.http_read_timeout,
write=config.http_write_timeout,
pool=config.http_pool_timeout,
),
limits=httpx.Limits(
max_connections=config.http_max_connections,
max_keepalive_connections=config.http_keepalive_connections,
keepalive_expiry=config.http_keepalive_expiry,
),
)
cls._delegate_clients[cache_key] = client
logger.debug("创建代发客户端(缓存): {}", cache_key)
return client
@classmethod
async def get_upstream_client(
cls,
delegate_cfg: dict[str, Any] | None,
proxy_config: dict[str, Any] | None = None,
) -> httpx.AsyncClient:
"""
获取可复用的上游请求客户端(自动选择代发或代理模式)
代发模式(delegate_cfg非空):返回代发客户端
直连/代理模式:返回代理客户端(含系统默认代理回退)
"""
if delegate_cfg:
return await cls.get_delegate_client(delegate_cfg)
return await cls.get_proxy_client(proxy_config=proxy_config)
@classmethod
def create_upstream_stream_client(
cls,
delegate_cfg: dict[str, Any] | None,
proxy_config: dict[str, Any] | None = None,
timeout: httpx.Timeout | None = None,
) -> httpx.AsyncClient:
"""
创建上游流式请求客户端(自动选择代发或代理模式)
调用者需负责关闭返回的客户端。
"""
if delegate_cfg:
return cls.create_delegate_stream_client(delegate_cfg, timeout=timeout)
return cls.create_client_with_proxy(proxy_config=proxy_config, timeout=timeout)
@classmethod
def get_pool_stats(cls) -> dict[str, Any]:
"""获取连接池统计信息"""
@@ -748,6 +502,7 @@ class HTTPClientPool:
"named_clients_count": len(cls._clients),
"proxy_clients_count": len(cls._proxy_clients),
"max_proxy_clients": cls._max_proxy_clients,
"delegate_clients_count": len(cls._delegate_clients),
}

View File

@@ -7,9 +7,10 @@ from urllib.parse import urlsplit, urlunsplit
import httpx
import jwt
from src.clients.http_client import HTTPClientPool, build_proxy_url
from src.clients.http_client import HTTPClientPool
from src.core.logger import logger
from src.core.provider_types import ProviderType
from src.services.proxy_node.resolver import build_proxy_url
_ANTHROPIC_TOKEN_URL = "https://console.anthropic.com/v1/oauth/token"
_GOOGLE_USERINFO_URL = "https://www.googleapis.com/oauth2/v1/userinfo?alt=json"

View File

@@ -848,6 +848,16 @@ class ProxyNode(Base):
String(128), nullable=True, comment="TLS 证书 SHA-256 指纹hex"
)
# 硬件信息注册时上报JSON 可扩展)
hardware_info = Column(
JSON,
nullable=True,
comment="硬件信息 (cpu_cores, total_memory_mb, os_info, fd_limit, ...)",
)
estimated_max_concurrency = Column(
Integer, nullable=True, comment="基于硬件估算的最大并发连接数"
)
# 管理端远程配置(通过心跳下发给 aether-proxy
remote_config = Column(
JSON,

View File

@@ -409,7 +409,7 @@ class AnyrouterArchitecture(ProviderArchitecture):
包含 acw_cookie 的配置
"""
# 从 config 获取代理配置(支持 proxy_node_id 和旧的 proxy URL
from src.clients.http_client import resolve_ops_proxy
from src.services.proxy_node.resolver import resolve_ops_proxy
proxy = resolve_ops_proxy(config)
acw_cookie = await _get_acw_cookie(base_url, proxy=proxy)

View File

@@ -51,7 +51,7 @@ class ProviderConnector(ABC):
self._last_error: str | None = None
# 代理配置(支持 proxy_node_id 和旧的 proxy URL
from src.clients.http_client import resolve_ops_proxy
from src.services.proxy_node.resolver import resolve_ops_proxy
self._proxy: str | httpx.Proxy | None = resolve_ops_proxy(self.config)

View File

@@ -180,7 +180,7 @@ class NekoCodeArchitecture(ProviderArchitecture):
"timeout": 10,
"verify": get_ssl_context(),
}
from src.clients.http_client import resolve_ops_proxy
from src.services.proxy_node.resolver import resolve_ops_proxy
proxy = resolve_ops_proxy(config)
if proxy:

View File

@@ -194,7 +194,7 @@ class YesCodeArchitecture(ProviderArchitecture):
cookie_header = _build_cookie_header(cookie_input)
# 获取代理配置(支持 proxy_node_id 和旧的 proxy URL
from src.clients.http_client import resolve_ops_proxy
from src.services.proxy_node.resolver import resolve_ops_proxy
proxy = resolve_ops_proxy(config)

View File

@@ -917,7 +917,7 @@ class ProviderOpsService:
)
# 获取代理配置(支持 proxy_node_id 和旧的 proxy URL
from src.clients.http_client import resolve_ops_proxy
from src.services.proxy_node.resolver import resolve_ops_proxy
proxy = resolve_ops_proxy(config)

View File

@@ -1,5 +1,43 @@
"""Proxy node services."""
"""代理节点服务"""
from .health_scheduler import ProxyNodeHealthScheduler, get_proxy_node_health_scheduler
from .resolver import (
build_delegate_post_kwargs,
build_delegate_stream_kwargs,
build_hmac_proxy_url,
build_post_kwargs,
build_proxy_url,
build_stream_kwargs,
compute_proxy_cache_key,
get_proxy_label,
get_system_proxy_config,
inject_auth_into_proxy_url,
invalidate_system_proxy_cache,
make_proxy_param,
resolve_delegate_config,
resolve_ops_proxy,
resolve_proxy_info,
)
from .service import ProxyNodeService, node_to_dict
__all__ = ["ProxyNodeHealthScheduler", "get_proxy_node_health_scheduler"]
__all__ = [
"ProxyNodeHealthScheduler",
"get_proxy_node_health_scheduler",
"ProxyNodeService",
"node_to_dict",
"build_delegate_post_kwargs",
"build_delegate_stream_kwargs",
"build_hmac_proxy_url",
"build_post_kwargs",
"build_proxy_url",
"build_stream_kwargs",
"compute_proxy_cache_key",
"inject_auth_into_proxy_url",
"make_proxy_param",
"get_proxy_label",
"get_system_proxy_config",
"invalidate_system_proxy_cache",
"resolve_delegate_config",
"resolve_ops_proxy",
"resolve_proxy_info",
]

View File

@@ -0,0 +1,673 @@
"""
代理解析服务
集中管理代理 URL 构建、节点信息缓存、系统默认代理回退、代理信息追踪等逻辑。
供 HTTPClientPool、Handler、Provider Ops 等模块调用。
"""
from __future__ import annotations
import base64
import hashlib
import hmac as _hmac
import time
from typing import Any
from urllib.parse import quote, urlparse
import httpx
from src.config import config
from src.core.exceptions import ProxyNodeUnavailableError
from src.core.logger import logger
# ---------------------------------------------------------------------------
# ProxyNode 信息缓存(降低高频 DB 查询开销)
# ---------------------------------------------------------------------------
_proxy_node_cache: dict[str, tuple[dict[str, Any] | None, float]] = {}
_PROXY_NODE_CACHE_TTL_SECONDS = 60.0
_PROXY_NODE_CACHE_MAX_SIZE = 256
def _get_proxy_node_info(node_id: str) -> dict[str, Any] | None:
"""
读取 ProxyNode 信息(带内存 TTL 缓存)
NOTE: 使用同步 DB sessioncreate_session在 async 上下文中会短暂阻塞
事件循环。60s TTL 缓存覆盖绝大多数请求,阻塞仅发生在缓存未命中时。
若后续 delegate 模式导致调用频率显著上升,应考虑改为 run_in_executor 包装。
Returns:
aether-proxy 节点: {"ip": str, "port": int, "name": str, ...}
手动节点: {"is_manual": True, "name": str, "proxy_url": str, ...}
不存在/非在线: None
"""
now = time.time()
cached = _proxy_node_cache.get(node_id)
if cached:
value, expires_at = cached
if now < expires_at:
return value
# 防止无效 node_id 导致缓存无限膨胀:淘汰最旧的条目而非全部清除
if len(_proxy_node_cache) >= _PROXY_NODE_CACHE_MAX_SIZE:
# 按过期时间排序,删除最旧的 25%
evict_count = _PROXY_NODE_CACHE_MAX_SIZE // 4
sorted_keys = sorted(_proxy_node_cache, key=lambda k: _proxy_node_cache[k][1])
for k in sorted_keys[:evict_count]:
del _proxy_node_cache[k]
from src.database import create_session
from src.models.database import ProxyNode, ProxyNodeStatus
db = create_session()
try:
node = db.query(ProxyNode).filter(ProxyNode.id == node_id).first()
if not node or node.status != ProxyNodeStatus.ONLINE:
_proxy_node_cache[node_id] = (None, now + _PROXY_NODE_CACHE_TTL_SECONDS)
return None
if node.is_manual:
value: dict[str, Any] = {
"is_manual": True,
"name": node.name,
"proxy_url": node.proxy_url,
"username": node.proxy_username,
"password": node.proxy_password,
}
else:
value = {
"name": node.name,
"ip": node.ip,
"port": node.port,
"tls_enabled": bool(node.tls_enabled),
"tls_cert_fingerprint": node.tls_cert_fingerprint,
}
_proxy_node_cache[node_id] = (value, now + _PROXY_NODE_CACHE_TTL_SECONDS)
return value
finally:
db.close()
# ---------------------------------------------------------------------------
# HMAC 签名
# ---------------------------------------------------------------------------
def build_hmac_proxy_url(ip: str, port: int, node_id: str, *, tls_enabled: bool = False) -> str:
"""
构建带 HMAC BasicAuth 的 httpx proxy URL
格式: http(s)://hmac:{timestamp}.{signature}@{ip}:{port}
signature = HMAC-SHA256(PROXY_HMAC_KEY, "{timestamp}\\n{node_id}") 的 hex
当 tls_enabled=True 时使用 https:// scheme。
"""
if not config.proxy_hmac_key:
logger.error("PROXY_HMAC_KEY 未配置,无法使用 ProxyNode 代理 (node_id={})", node_id)
raise ProxyNodeUnavailableError(
"PROXY_HMAC_KEY 未配置,无法使用 ProxyNode 代理", node_id=node_id
)
timestamp = str(int(time.time()))
payload = f"{timestamp}\n{node_id}".encode("utf-8")
signature = _hmac.new(
config.proxy_hmac_key.encode("utf-8"),
payload,
hashlib.sha256,
).hexdigest()
host = f"[{ip}]" if ":" in ip else ip
scheme = "https" if tls_enabled else "http"
return f"{scheme}://hmac:{timestamp}.{signature}@{host}:{int(port)}"
# ---------------------------------------------------------------------------
# 系统默认代理
# ---------------------------------------------------------------------------
_system_proxy_cache: tuple[dict[str, Any] | None, float] | None = None
_SYSTEM_PROXY_CACHE_TTL = 60.0
def invalidate_system_proxy_cache() -> None:
"""手动失效系统代理缓存(在删除节点等操作后调用)"""
global _system_proxy_cache
_system_proxy_cache = None
def get_system_proxy_config() -> dict[str, Any] | None:
"""
获取系统默认代理配置(带 TTL 缓存)
从 system_configs 表中读取 system_proxy_node_id。
返回 {"node_id": "...", "enabled": True} 或 None。
"""
global _system_proxy_cache
now = time.time()
if _system_proxy_cache:
value, expires_at = _system_proxy_cache
if now < expires_at:
return value
from src.database import create_session
from src.services.system.config import SystemConfigService
db = create_session()
try:
node_id = SystemConfigService.get_config(db, "system_proxy_node_id")
if node_id and isinstance(node_id, str) and node_id.strip():
result: dict[str, Any] | None = {"node_id": node_id.strip(), "enabled": True}
else:
result = None
_system_proxy_cache = (result, now + _SYSTEM_PROXY_CACHE_TTL)
return result
except Exception as exc:
logger.warning("获取系统默认代理配置失败: {}", exc)
_system_proxy_cache = (None, now + _SYSTEM_PROXY_CACHE_TTL)
return None
finally:
db.close()
# ---------------------------------------------------------------------------
# 代理 URL 认证注入
# ---------------------------------------------------------------------------
def inject_auth_into_proxy_url(proxy_url: str, username: str, password: str | None = None) -> str:
"""将用户名密码注入代理 URLURL 编码处理特殊字符)"""
parsed = urlparse(proxy_url)
encoded_username = quote(username, safe="")
encoded_password = quote(password, safe="") if password else ""
host_part = parsed.hostname or "localhost"
if parsed.port:
host_part = f"{host_part}:{parsed.port}"
if encoded_password:
auth_url = f"{parsed.scheme}://{encoded_username}:{encoded_password}@{host_part}"
else:
auth_url = f"{parsed.scheme}://{encoded_username}@{host_part}"
if parsed.path:
auth_url += parsed.path
return auth_url
# ---------------------------------------------------------------------------
# TLS 代理参数
# ---------------------------------------------------------------------------
def make_proxy_param(proxy_url: str | None) -> str | httpx.Proxy | None:
"""
根据代理 URL 返回 httpx 可接受的 proxy 参数。
对于 https:// scheme 的代理 URLTLS aether-proxy 节点),返回 httpx.Proxy
并附带 proxy_ssl_contextCERT_NONE因为使用自签名证书
其他情况返回普通 URL 字符串。
"""
if not proxy_url:
return None
# https:// 代理需要 ssl_context自签名证书场景
if proxy_url.startswith("https://"):
from src.utils.ssl_utils import get_proxy_ssl_context
return httpx.Proxy(url=proxy_url, ssl_context=get_proxy_ssl_context())
return proxy_url
# ---------------------------------------------------------------------------
# Ops connector 代理解析
# ---------------------------------------------------------------------------
def resolve_ops_proxy(
connector_config: dict[str, Any] | None,
) -> str | httpx.Proxy | None:
"""
从 ops connector.config 中解析代理参数(含系统默认回退)
优先级:
1. connector_config.proxy_node_id新格式
2. connector_config.proxy旧格式 URL 字符串)
3. 系统默认代理节点
Args:
connector_config: connector 的 config 字典
Returns:
httpx 可接受的代理参数str 或 httpx.Proxy或 None
"""
if connector_config:
# 新格式proxy_node_id -> 通过 build_proxy_url 解析
node_id = connector_config.get("proxy_node_id")
if isinstance(node_id, str) and node_id.strip():
try:
url = build_proxy_url({"node_id": node_id.strip(), "enabled": True})
return make_proxy_param(url)
except Exception as exc:
logger.warning("解析 proxy_node_id={} 失败,回退到直连: {}", node_id, exc)
return None
# 旧格式:直接返回 proxy URL 字符串
proxy = connector_config.get("proxy")
if isinstance(proxy, str) and proxy.strip():
return proxy
# 回退:系统默认代理
system_proxy = get_system_proxy_config()
if system_proxy:
try:
url = build_proxy_url(system_proxy)
return make_proxy_param(url)
except Exception as exc:
logger.warning("构建系统默认代理 URL 失败: {}", exc)
return None
return None
# ---------------------------------------------------------------------------
# 代理 URL 构建
# ---------------------------------------------------------------------------
def build_proxy_url(proxy_config: dict[str, Any]) -> str | None:
"""
根据代理配置构建完整的代理 URL
Args:
proxy_config: 代理配置字典,支持两种模式:
- 手动 URL 模式: {url, username, password, enabled}
- ProxyNode 模式: {node_id, enabled}
Returns:
完整的代理 URL如 socks5://user:pass@host:port
如果 enabled=False 或无配置,返回 None
"""
if not proxy_config:
return None
# 检查 enabled 字段,默认为 True兼容旧数据
if not proxy_config.get("enabled", True):
return None
# ProxyNode 模式aether-proxy 或手动节点)
node_id = proxy_config.get("node_id")
if isinstance(node_id, str) and node_id.strip():
node_id = node_id.strip()
node_info = _get_proxy_node_info(node_id)
if not node_info:
logger.warning("代理节点不可用(离线或不存在): node_id={}", node_id)
raise ProxyNodeUnavailableError(f"代理节点 {node_id} 不可用", node_id=node_id)
# 手动节点:直接使用存储的代理 URL含认证信息
if node_info.get("is_manual"):
manual_url = node_info.get("proxy_url")
if not manual_url:
raise ProxyNodeUnavailableError(
f"手动代理节点 {node_id} 缺少 proxy_url", node_id=node_id
)
username = node_info.get("username")
password = node_info.get("password")
if username:
return inject_auth_into_proxy_url(manual_url, username, password)
return manual_url
# aether-proxy 节点:使用 HMAC 认证
return build_hmac_proxy_url(
node_info["ip"],
node_info["port"],
node_id,
tls_enabled=node_info.get("tls_enabled", False),
)
proxy_url: str | None = proxy_config.get("url")
if not proxy_url:
return None
username = proxy_config.get("username")
password = proxy_config.get("password")
# 只要有用户名就添加认证信息(密码可以为空)
if username:
return inject_auth_into_proxy_url(proxy_url, username, password)
return proxy_url
# ---------------------------------------------------------------------------
# 代理信息追踪(日志/usage
# ---------------------------------------------------------------------------
def resolve_proxy_info(proxy_config: dict[str, Any] | None) -> dict[str, Any] | None:
"""
解析代理配置的摘要信息(用于日志和 usage 记录)
不构建实际的代理 URL仅返回可读的代理标识信息。
Returns:
{"node_id": "xxx", "node_name": "proxy-01", "source": "provider"} 或
{"url": "socks5://host:port", "source": "provider"} 或
{"node_id": "xxx", "node_name": "...", "source": "system"} 或
None (直连)
"""
source = "provider"
effective_config = proxy_config
# 无 provider 级代理时,尝试系统默认代理
if not effective_config or not effective_config.get("enabled", True):
effective_config = get_system_proxy_config()
source = "system"
if not effective_config or not effective_config.get("enabled", True):
return None
# ProxyNode 模式
node_id = effective_config.get("node_id")
if isinstance(node_id, str) and node_id.strip():
node_id = node_id.strip()
node_info = _get_proxy_node_info(node_id)
node_name = node_info.get("name", "unknown") if node_info else "offline"
return {"node_id": node_id, "node_name": node_name, "source": source}
# 旧格式 URL 模式
proxy_url = effective_config.get("url")
if proxy_url:
# 脱敏:只保留 scheme + host + port
try:
parsed = urlparse(proxy_url)
host_part = parsed.hostname or "unknown"
if parsed.port:
host_part = f"{host_part}:{parsed.port}"
safe_url = f"{parsed.scheme}://{host_part}"
except Exception:
safe_url = "unknown"
return {"url": safe_url, "source": source}
return None
def get_proxy_label(proxy_info: dict[str, Any] | None) -> str:
"""从 proxy_info 中提取简短的代理标签(用于日志)"""
if not proxy_info:
return "direct"
return proxy_info.get("node_name") or proxy_info.get("url") or "unknown"
# ---------------------------------------------------------------------------
# 代理缓存键计算(供 HTTPClientPool 使用)
# ---------------------------------------------------------------------------
def compute_proxy_cache_key(proxy_config: dict[str, Any] | None) -> str:
"""
计算代理配置的缓存键
Args:
proxy_config: 代理配置字典
Returns:
缓存键字符串,无代理时返回 "__no_proxy__"
"""
if not proxy_config:
return "__no_proxy__"
# enabled=False 时视为无代理(兼容旧数据)
if not proxy_config.get("enabled", True):
return "__no_proxy__"
# ProxyNode 模式:基于 node_id + 时间桶缓存,避免签名随时间变化导致 cache key 爆炸
node_id = proxy_config.get("node_id")
if isinstance(node_id, str) and node_id.strip():
time_bucket = int(time.time() / 120) # 120 秒一个桶
return f"proxy_node:{node_id.strip()}:{time_bucket}"
# 构建代理 URL 作为缓存键的基础
proxy_url = build_proxy_url(proxy_config)
if not proxy_url:
return "__no_proxy__"
# 使用 MD5 哈希来避免过长的键名
return f"proxy:{hashlib.md5(proxy_url.encode()).hexdigest()[:16]}"
# ---------------------------------------------------------------------------
# 代发模式 (Delegate API)
# ---------------------------------------------------------------------------
def _build_hmac_auth_header(node_id: str) -> str:
"""
构建代发请求的 Authorization 头
格式: Basic base64(hmac:{timestamp}.{signature})
签名算法与 build_hmac_proxy_url 相同。
"""
if not config.proxy_hmac_key:
raise ProxyNodeUnavailableError("PROXY_HMAC_KEY 未配置,无法使用代发模式", node_id=node_id)
timestamp = str(int(time.time()))
payload = f"{timestamp}\n{node_id}".encode("utf-8")
signature = _hmac.new(
config.proxy_hmac_key.encode("utf-8"),
payload,
hashlib.sha256,
).hexdigest()
cred = f"hmac:{timestamp}.{signature}"
encoded = base64.b64encode(cred.encode()).decode()
return f"Basic {encoded}"
def resolve_delegate_config(proxy_config: dict[str, Any] | None) -> dict[str, Any] | None:
"""
解析代发配置(仅 aether-proxy 节点支持,手动节点/旧格式 URL 不支持)
无特定代理时自动回退到系统默认代理。
auth_header 延迟生成:通过 ``fresh_auth_header()`` 闭包在每次请求 / 重试时
获取新鲜的 HMAC 签名,避免长生命周期内时间戳过期。
Returns:
{"delegate_url": str, "node_id": str, "tls_enabled": bool,
"auth_header": str, # 首次生成的签名(兼容旧调用)
"fresh_auth_header": Callable} # 延迟生成签名的闭包
或 None
"""
effective_config = proxy_config
if not effective_config or not effective_config.get("enabled", True):
effective_config = get_system_proxy_config()
if not effective_config or not effective_config.get("enabled", True):
return None
node_id = effective_config.get("node_id")
if not isinstance(node_id, str) or not node_id.strip():
return None # 旧格式 URL 模式不支持代发
node_id = node_id.strip()
node_info = _get_proxy_node_info(node_id)
if not node_info or node_info.get("is_manual"):
return None # 手动节点不支持代发
tls_enabled = node_info.get("tls_enabled", False)
host = f"[{node_info['ip']}]" if ":" in node_info["ip"] else node_info["ip"]
scheme = "https" if tls_enabled else "http"
delegate_url = f"{scheme}://{host}:{int(node_info['port'])}/_aether/delegate"
# 闭包捕获 node_id每次调用生成新鲜签名
def _fresh() -> str:
return _build_hmac_auth_header(node_id)
return {
"delegate_url": delegate_url,
"auth_header": _fresh(), # 立即生成一份,兼容旧调用方
"fresh_auth_header": _fresh,
"node_id": node_id,
"tls_enabled": tls_enabled,
}
# ---------------------------------------------------------------------------
# 代发请求参数构建(消除 handler 层重复代码)
# ---------------------------------------------------------------------------
_JSON_CT = "application/json"
def _build_delegate_kwargs_core(
delegate_cfg: dict[str, Any],
*,
url: str,
headers: dict[str, str],
payload: Any,
timeout: float,
refresh_auth: bool = False,
) -> dict[str, Any]:
"""
构建代发请求的核心参数post/stream 共用)
Args:
delegate_cfg: resolve_delegate_config 返回的配置
url: 上游实际 URL
headers: 上游请求头
payload: 上游 JSON body可以为 None
timeout: 上游超时秒数
refresh_auth: 为 True 时重新生成 HMAC 签名(用于 retry
"""
import json as _json
auth = (
delegate_cfg["fresh_auth_header"]()
if refresh_auth
else delegate_cfg.get("auth_header") or delegate_cfg["fresh_auth_header"]()
)
return {
"url": delegate_cfg["delegate_url"],
"json": {
"method": "POST",
"url": url,
"headers": headers,
"body": _json.dumps(payload, ensure_ascii=False) if payload is not None else None,
"timeout": int(timeout),
},
"headers": {"Authorization": auth, "Content-Type": _JSON_CT},
"timeout": httpx.Timeout(timeout + 10),
}
def build_delegate_post_kwargs(
delegate_cfg: dict[str, Any],
*,
url: str,
headers: dict[str, str],
payload: Any,
timeout: float,
refresh_auth: bool = False,
) -> dict[str, Any]:
"""构建代发 POST 请求的 httpx kwargs非流式传给 client.post"""
return _build_delegate_kwargs_core(
delegate_cfg,
url=url,
headers=headers,
payload=payload,
timeout=timeout,
refresh_auth=refresh_auth,
)
def build_delegate_stream_kwargs(
delegate_cfg: dict[str, Any],
*,
url: str,
headers: dict[str, str],
payload: Any,
timeout: float,
refresh_auth: bool = False,
) -> dict[str, Any]:
"""构建代发 stream 请求的 httpx kwargs传给 client.stream"""
kwargs = _build_delegate_kwargs_core(
delegate_cfg,
url=url,
headers=headers,
payload=payload,
timeout=timeout,
refresh_auth=refresh_auth,
)
# stream() 需要显式 method 参数
kwargs["method"] = "POST"
return kwargs
# ---------------------------------------------------------------------------
# 统一上游请求参数构建(消除 handler 层 delegate/直连 分支重复)
# ---------------------------------------------------------------------------
def build_post_kwargs(
delegate_cfg: dict[str, Any] | None,
*,
url: str,
headers: dict[str, str],
payload: Any,
timeout: float,
refresh_auth: bool = False,
) -> dict[str, Any]:
"""
构建上游 POST 请求的 httpx kwargs自动选择代发或直连模式
返回的 dict 可直接传给 ``http_client.post(**kwargs)``。
"""
if delegate_cfg:
return build_delegate_post_kwargs(
delegate_cfg,
url=url,
headers=headers,
payload=payload,
timeout=timeout,
refresh_auth=refresh_auth,
)
return {
"url": url,
"json": payload,
"headers": headers,
"timeout": httpx.Timeout(timeout),
}
def build_stream_kwargs(
delegate_cfg: dict[str, Any] | None,
*,
url: str,
headers: dict[str, str],
payload: Any,
timeout: float | None = None,
) -> dict[str, Any]:
"""
构建上游 stream 请求的 httpx kwargs自动选择代发或直连模式
返回的 dict 可直接传给 ``http_client.stream(**kwargs)``。
当 ``timeout`` 为 None直连模式下由外层 asyncio.wait_for 控制超时),
直连分支不设置 timeout代发分支始终携带 timeoutproxy 协议需要)。
"""
if delegate_cfg:
return build_delegate_stream_kwargs(
delegate_cfg,
url=url,
headers=headers,
payload=payload,
timeout=timeout or 60,
)
kwargs: dict[str, Any] = {
"method": "POST",
"url": url,
"json": payload,
"headers": headers,
}
if timeout is not None:
kwargs["timeout"] = httpx.Timeout(timeout)
return kwargs

View File

@@ -0,0 +1,477 @@
"""
代理节点 CRUD 服务
提供 ProxyNode 的注册、心跳、注销、手动节点管理、连通性测试、远程配置等业务逻辑。
路由层routes.py通过此 service 操作数据库,不再直接编写 DB 查询。
"""
from __future__ import annotations
import re
import uuid
from datetime import datetime, timezone
from typing import Any
from urllib.parse import urlparse
import httpx
from sqlalchemy.orm import Session
from src.core.exceptions import InvalidRequestException, NotFoundException
from src.models.database import ProxyNode, ProxyNodeStatus, SystemConfig
from .resolver import (
build_hmac_proxy_url,
inject_auth_into_proxy_url,
invalidate_system_proxy_cache,
make_proxy_param,
)
# ---------------------------------------------------------------------------
# 辅助函数
# ---------------------------------------------------------------------------
def _mask_password(password: str | None) -> str | None:
"""脱敏密码仅显示前2位和后2位长度不足 8 时全部遮蔽)"""
if not password:
return None
if len(password) < 8:
return "****"
return password[:2] + "****" + password[-2:]
def node_to_dict(node: ProxyNode) -> dict[str, Any]:
"""将 ProxyNode 实例序列化为字典(供 API 响应使用)"""
d = {
"id": node.id,
"name": node.name,
"ip": node.ip,
"port": node.port,
"region": node.region,
"status": node.status.value if node.status else None,
"is_manual": bool(node.is_manual),
"registered_by": node.registered_by,
"last_heartbeat_at": node.last_heartbeat_at,
"heartbeat_interval": node.heartbeat_interval,
"active_connections": node.active_connections,
"total_requests": node.total_requests,
"avg_latency_ms": node.avg_latency_ms,
"tls_enabled": bool(node.tls_enabled),
"tls_cert_fingerprint": node.tls_cert_fingerprint,
"hardware_info": node.hardware_info,
"estimated_max_concurrency": node.estimated_max_concurrency,
"remote_config": node.remote_config,
"config_version": node.config_version,
"created_at": node.created_at,
"updated_at": node.updated_at,
}
# 手动节点附带代理配置(密码脱敏)
if node.is_manual:
d["proxy_url"] = node.proxy_url
d["proxy_username"] = node.proxy_username
d["proxy_password"] = _mask_password(node.proxy_password)
return d
def _parse_host_port(proxy_url: str) -> tuple[str, int]:
"""从代理 URL 中解析 host 和 port含协议前缀避免唯一约束冲突"""
parsed = urlparse(proxy_url)
host = parsed.hostname or "manual"
default_ports = {"https": 443, "socks5": 1080}
port = parsed.port or default_ports.get((parsed.scheme or "").lower(), 80)
# 添加协议前缀区分同 host:port 不同协议的场景
scheme = (parsed.scheme or "http").lower()
if scheme != "http":
host = f"{scheme}://{host}"
return host, port
def _sanitize_proxy_error(err: Exception) -> str:
"""去除异常消息中可能包含的代理 URL 凭据(如 HMAC 签名)"""
return re.sub(r"://[^@/]+@", "://***@", str(err))
def _build_test_proxy_url(node: ProxyNode) -> str:
"""为测试连通性构建代理 URL无需节点在线"""
if node.is_manual:
proxy_url = node.proxy_url
if not proxy_url:
raise InvalidRequestException("手动节点缺少 proxy_url")
if node.proxy_username:
proxy_url = inject_auth_into_proxy_url(
proxy_url, node.proxy_username, node.proxy_password
)
return proxy_url
else:
# aether-proxy: 使用 HMAC 认证构建代理 URL
return build_hmac_proxy_url(node.ip, node.port, node.id, tls_enabled=bool(node.tls_enabled))
# ---------------------------------------------------------------------------
# ProxyNodeService
# ---------------------------------------------------------------------------
class ProxyNodeService:
"""代理节点 CRUD 服务"""
@staticmethod
def register_node(
db: Session,
*,
name: str,
ip: str,
port: int,
region: str | None = None,
heartbeat_interval: int = 30,
tls_enabled: bool = False,
tls_cert_fingerprint: str | None = None,
hardware_info: dict[str, Any] | None = None,
estimated_max_concurrency: int | None = None,
active_connections: int | None = None,
total_requests: int | None = None,
avg_latency_ms: float | None = None,
registered_by: str | None = None,
) -> ProxyNode:
"""注册或更新 aether-proxy 节点(按 ip+port upsert"""
now = datetime.now(timezone.utc)
node = db.query(ProxyNode).filter(ProxyNode.ip == ip, ProxyNode.port == port).first()
if node:
node.name = name
node.region = region
node.status = ProxyNodeStatus.ONLINE
node.last_heartbeat_at = now
node.heartbeat_interval = heartbeat_interval
node.tls_enabled = tls_enabled
node.tls_cert_fingerprint = tls_cert_fingerprint
if hardware_info is not None:
node.hardware_info = hardware_info
if estimated_max_concurrency is not None:
node.estimated_max_concurrency = estimated_max_concurrency
if active_connections is not None:
node.active_connections = active_connections
if total_requests is not None:
node.total_requests = total_requests
if avg_latency_ms is not None:
node.avg_latency_ms = avg_latency_ms
else:
node = ProxyNode(
id=str(uuid.uuid4()),
name=name,
ip=ip,
port=port,
region=region,
status=ProxyNodeStatus.ONLINE,
registered_by=registered_by,
last_heartbeat_at=now,
heartbeat_interval=heartbeat_interval,
active_connections=active_connections or 0,
total_requests=total_requests or 0,
avg_latency_ms=avg_latency_ms,
tls_enabled=tls_enabled,
tls_cert_fingerprint=tls_cert_fingerprint,
hardware_info=hardware_info,
estimated_max_concurrency=estimated_max_concurrency,
created_at=now,
updated_at=now,
)
db.add(node)
db.commit()
db.refresh(node)
return node
@staticmethod
def heartbeat(
db: Session,
*,
node_id: str,
heartbeat_interval: int | None = None,
active_connections: int | None = None,
total_requests: int | None = None,
avg_latency_ms: float | None = None,
) -> ProxyNode:
"""处理节点心跳"""
node = db.query(ProxyNode).filter(ProxyNode.id == node_id).first()
if not node:
raise NotFoundException(f"ProxyNode {node_id} 不存在", "proxy_node")
now = datetime.now(timezone.utc)
node.status = ProxyNodeStatus.ONLINE
node.last_heartbeat_at = now
if heartbeat_interval is not None:
node.heartbeat_interval = heartbeat_interval
if active_connections is not None:
node.active_connections = active_connections
if total_requests is not None:
node.total_requests = total_requests
if avg_latency_ms is not None:
node.avg_latency_ms = avg_latency_ms
db.commit()
db.refresh(node)
return node
@staticmethod
def unregister_node(db: Session, *, node_id: str) -> ProxyNode:
"""注销节点(设置为 OFFLINE"""
node = db.query(ProxyNode).filter(ProxyNode.id == node_id).first()
if not node:
raise NotFoundException(f"ProxyNode {node_id} 不存在", "proxy_node")
node.status = ProxyNodeStatus.OFFLINE
node.updated_at = datetime.now(timezone.utc)
db.commit()
return node
@staticmethod
def list_nodes(
db: Session,
*,
status: str | None = None,
skip: int = 0,
limit: int = 100,
) -> tuple[list[ProxyNode], int]:
"""列出代理节点(支持按状态筛选和分页)"""
query = db.query(ProxyNode)
if status:
normalized = status.strip().lower()
allowed = {"online", "unhealthy", "offline"}
if normalized not in allowed:
raise InvalidRequestException(f"status 必须是以下之一: {sorted(allowed)}", "status")
query = query.filter(ProxyNode.status == ProxyNodeStatus(normalized))
total = query.count()
nodes = query.order_by(ProxyNode.updated_at.desc()).offset(skip).limit(limit).all()
return nodes, total
@staticmethod
def create_manual_node(
db: Session,
*,
name: str,
proxy_url: str,
username: str | None = None,
password: str | None = None,
region: str | None = None,
registered_by: str | None = None,
) -> ProxyNode:
"""创建手动代理节点"""
host, port = _parse_host_port(proxy_url)
now = datetime.now(timezone.utc)
# 检查是否已存在同地址的节点
existing = db.query(ProxyNode).filter(ProxyNode.ip == host, ProxyNode.port == port).first()
if existing:
raise InvalidRequestException(
f"已存在相同地址的代理节点: {existing.name} ({existing.ip}:{existing.port})"
)
node = ProxyNode(
id=str(uuid.uuid4()),
name=name,
ip=host,
port=port,
region=region,
is_manual=True,
proxy_url=proxy_url,
proxy_username=username,
proxy_password=password,
status=ProxyNodeStatus.ONLINE,
registered_by=registered_by,
last_heartbeat_at=None,
heartbeat_interval=0,
active_connections=0,
total_requests=0,
avg_latency_ms=None,
created_at=now,
updated_at=now,
)
db.add(node)
db.commit()
db.refresh(node)
return node
@staticmethod
def update_manual_node(
db: Session,
*,
node_id: str,
name: str | None = None,
proxy_url: str | None = None,
username: str | None = None,
password: str | None = None,
region: str | None = None,
) -> ProxyNode:
"""更新手动代理节点"""
node = db.query(ProxyNode).filter(ProxyNode.id == node_id).first()
if not node:
raise NotFoundException(f"ProxyNode {node_id} 不存在", "proxy_node")
if not node.is_manual:
raise InvalidRequestException("只能编辑手动添加的代理节点")
if name is not None:
node.name = name
if proxy_url is not None:
host, port = _parse_host_port(proxy_url)
# 检查新地址是否与其他节点冲突
existing = (
db.query(ProxyNode)
.filter(ProxyNode.ip == host, ProxyNode.port == port, ProxyNode.id != node.id)
.first()
)
if existing:
raise InvalidRequestException(
f"已存在相同地址的代理节点: {existing.name} ({existing.ip}:{existing.port})"
)
node.proxy_url = proxy_url
node.ip = host
node.port = port
if username is not None:
node.proxy_username = username
# password: None=不发送(保留原值), ""=清空, 非空=更新
if password is not None:
node.proxy_password = password or None
if region is not None:
node.region = region
node.updated_at = datetime.now(timezone.utc)
db.commit()
db.refresh(node)
return node
@staticmethod
def delete_node(db: Session, *, node_id: str) -> dict[str, Any]:
"""
删除代理节点
若该节点是系统默认代理,自动清除引用。
返回 {"node_id": ..., "cleared_system_proxy": bool}
"""
node = db.query(ProxyNode).filter(ProxyNode.id == node_id).first()
if not node:
raise NotFoundException(f"ProxyNode {node_id} 不存在", "proxy_node")
# 若该节点是系统默认代理,自动清除引用
was_system_proxy = False
sys_cfg = db.query(SystemConfig).filter(SystemConfig.key == "system_proxy_node_id").first()
if sys_cfg and sys_cfg.value == node_id:
sys_cfg.value = None
was_system_proxy = True
node_info = {"proxy_node_ip": node.ip, "proxy_node_port": node.port}
db.delete(node)
db.commit()
if was_system_proxy:
invalidate_system_proxy_cache()
return {
"node_id": node_id,
"node_info": node_info,
"cleared_system_proxy": was_system_proxy,
}
@staticmethod
async def test_node(db: Session, *, node_id: str) -> dict[str, Any]:
"""测试代理节点连通性和延迟"""
import time as _time
node = db.query(ProxyNode).filter(ProxyNode.id == node_id).first()
if not node:
raise NotFoundException(f"ProxyNode {node_id} 不存在", "proxy_node")
# 构建代理 URL
try:
proxy_url = _build_test_proxy_url(node)
except Exception as exc:
return {"success": False, "latency_ms": None, "exit_ip": None, "error": str(exc)}
test_url = "https://1.1.1.1/cdn-cgi/trace"
start = _time.monotonic()
proxy_param = make_proxy_param(proxy_url)
try:
async with httpx.AsyncClient(
proxy=proxy_param,
timeout=httpx.Timeout(15.0, connect=10.0),
) as client:
response = await client.get(test_url)
elapsed_ms = round((_time.monotonic() - start) * 1000, 1)
exit_ip = None
if response.status_code == 200:
for line in response.text.splitlines():
if line.startswith("ip="):
exit_ip = line.split("=", 1)[1].strip()
break
return {
"success": True,
"latency_ms": elapsed_ms,
"exit_ip": exit_ip,
"error": None,
}
except httpx.ProxyError as exc:
elapsed_ms = round((_time.monotonic() - start) * 1000, 1)
return {
"success": False,
"latency_ms": elapsed_ms,
"exit_ip": None,
"error": f"代理连接失败: {_sanitize_proxy_error(exc)}",
}
except httpx.ConnectError as exc:
elapsed_ms = round((_time.monotonic() - start) * 1000, 1)
return {
"success": False,
"latency_ms": elapsed_ms,
"exit_ip": None,
"error": f"连接失败: {_sanitize_proxy_error(exc)}",
}
except httpx.TimeoutException:
elapsed_ms = round((_time.monotonic() - start) * 1000, 1)
return {
"success": False,
"latency_ms": elapsed_ms,
"exit_ip": None,
"error": "连接超时15秒",
}
except Exception as exc:
elapsed_ms = round((_time.monotonic() - start) * 1000, 1)
return {
"success": False,
"latency_ms": elapsed_ms,
"exit_ip": None,
"error": _sanitize_proxy_error(exc),
}
@staticmethod
def update_node_config(
db: Session, *, node_id: str, config_updates: dict[str, Any]
) -> ProxyNode:
"""更新 aether-proxy 节点的远程配置(通过下次心跳下发)"""
node = db.query(ProxyNode).filter(ProxyNode.id == node_id).first()
if not node:
raise NotFoundException(f"ProxyNode {node_id} 不存在", "proxy_node")
if node.is_manual:
raise InvalidRequestException("手动节点不支持远程配置下发")
# node_name is special: it also updates the node.name column directly
if "node_name" in config_updates:
node.name = config_updates["node_name"]
# Merge with existing config (so partial updates are preserved)
# Copy to a new dict so SQLAlchemy detects the change on the JSON column
existing = dict(node.remote_config) if node.remote_config else {}
existing.update(config_updates)
node.remote_config = existing
node.config_version = (node.config_version or 0) + 1
node.updated_at = datetime.now(timezone.utc)
db.commit()
db.refresh(node)
return node

View File

@@ -180,7 +180,7 @@ class RequestExecutor:
)
else:
# 非流式请求:标记为 success 状态
from src.clients.http_client import resolve_proxy_info
from src.services.proxy_node.resolver import resolve_proxy_info
_extra: dict[str, Any] = {
"is_cached_user": is_cached_user,

View File

@@ -699,7 +699,6 @@ class TaskService:
"""
import httpx
from src.clients.http_client import resolve_proxy_info
from src.core.api_format.conversion.exceptions import FormatConversionError
from src.core.error_utils import extract_error_message
from src.core.exceptions import (
@@ -709,6 +708,7 @@ class TaskService:
ThinkingSignatureException,
UpstreamClientException,
)
from src.services.proxy_node.resolver import resolve_proxy_info
from src.services.request.executor import ExecutionError
# 提前解析代理信息,写入候选记录的 extra_data用于链路追踪展示