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

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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