mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-03 01:40:21 +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:
@@ -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