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

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