mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-02 09:20:22 +08:00
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:
@@ -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),
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 的代理 URL(TLS aether-proxy 节点),返回 httpx.Proxy
|
||||
并附带 proxy_ssl_context(CERT_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),
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
673
src/services/proxy_node/resolver.py
Normal file
673
src/services/proxy_node/resolver.py
Normal 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 session(create_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:
|
||||
"""将用户名密码注入代理 URL(URL 编码处理特殊字符)"""
|
||||
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 的代理 URL(TLS aether-proxy 节点),返回 httpx.Proxy
|
||||
并附带 proxy_ssl_context(CERT_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;代发分支始终携带 timeout(proxy 协议需要)。
|
||||
"""
|
||||
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
|
||||
477
src/services/proxy_node/service.py
Normal file
477
src/services/proxy_node/service.py
Normal 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
|
||||
@@ -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,
|
||||
|
||||
@@ -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(用于链路追踪展示)
|
||||
|
||||
Reference in New Issue
Block a user