refactor(proxy): 将 aether-proxy 从 HMAC 正向代理迁移到 WebSocket 隧道模式

移除 HMAC 认证、TLS 自签名证书、HTTP CONNECT 代理和代发(delegate)模式,
改为 aether-proxy 主动通过 WebSocket 连接 Aether 服务端建立隧道。

Aether 服务端新增:
- WebSocket 隧道端点 (proxy_tunnel.py)
- TunnelManager 管理隧道连接和请求分发
- TunnelTransport 作为 httpx 自定义 transport 层
- 基于二进制帧的隧道协议 (tunnel_protocol.py)

aether-proxy (Rust) 重构:
- 新增 tunnel 模块 (client/dispatcher/stream_handler/protocol)
- 支持多 Aether 服务端连接 ([[servers]] 配置)
- 移除 proxy/auth/delegate 模块和 hyper 依赖
- 改用 tokio-tungstenite 实现 WebSocket 客户端

同时:
- 添加浏览器指纹 Headers 绕过 Cloudflare 防护
- 删除节点时自动清理 Provider/Endpoint 的代理引用
- 数据库迁移: 新增 tunnel_mode/tunnel_connected/tunnel_connected_at 字段
This commit is contained in:
fawney19
2026-02-25 21:59:29 +08:00
parent 39b036abd5
commit fd9040b9aa
53 changed files with 2938 additions and 2728 deletions

View File

@@ -51,9 +51,10 @@ class ProviderConnector(ABC):
self._last_error: str | None = None
# 代理配置(支持 proxy_node_id 和旧的 proxy URL
from src.services.proxy_node.resolver import resolve_ops_proxy
from src.services.proxy_node.resolver import resolve_ops_proxy, resolve_ops_tunnel_node_id
self._proxy: str | httpx.Proxy | None = resolve_ops_proxy(self.config)
self._tunnel_node_id: str | None = resolve_ops_tunnel_node_id(self.config)
# HTTP 客户端配置
self._timeout = self.config.get("timeout", 30)
@@ -117,13 +118,18 @@ class ProviderConnector(ABC):
"""
获取已认证的 HTTP 客户端
使用 context manager 确保资源正确释放
使用 context manager 确保资源正确释放
tunnel 模式下使用 TunnelTransport 替代 proxy transport。
Yields:
已配置认证信息的 AsyncClient
"""
transport = None
if self._proxy:
if self._tunnel_node_id:
from src.services.proxy_node.tunnel_transport import TunnelTransport
transport = TunnelTransport(self._tunnel_node_id, timeout=self._timeout)
elif self._proxy:
transport = httpx.AsyncHTTPTransport(proxy=self._proxy)
async with httpx.AsyncClient(

View File

@@ -8,6 +8,7 @@ from typing import Any
import httpx
from src.core.api_format.headers import BROWSER_FINGERPRINT_HEADERS
from src.services.provider_ops.actions import (
NewApiBalanceAction,
ProviderAction,
@@ -75,6 +76,10 @@ class NewApiConnector(ProviderConnector):
def _apply_auth(self, request: httpx.Request) -> httpx.Request:
"""为请求应用认证信息"""
# 添加浏览器指纹 Headers 以绕过 Cloudflare 等防护
for key, value in BROWSER_FINGERPRINT_HEADERS.items():
request.headers.setdefault(key, value)
if self._api_key:
request.headers["Authorization"] = f"Bearer {self._api_key}"
if self._user_id:
@@ -206,7 +211,8 @@ class NewApiArchitecture(ProviderArchitecture):
New API 特有:需要 New-Api-User Header 传递用户 ID
"""
headers: dict[str, str] = {}
# 以浏览器指纹 Headers 为基础,绕过 Cloudflare 等防护
headers: dict[str, str] = {**BROWSER_FINGERPRINT_HEADERS}
# Bearer Token 认证
api_key = credentials.get("api_key", "")

View File

@@ -1033,10 +1033,11 @@ class ProviderOpsService:
list(headers.keys()),
)
# 获取代理配置(支持 proxy_node_id 和旧的 proxy URL
from src.services.proxy_node.resolver import resolve_ops_proxy
# 获取代理配置(支持 proxy_node_id、tunnel 模式和旧的 proxy URL
from src.services.proxy_node.resolver import resolve_ops_proxy, resolve_ops_tunnel_node_id
proxy = resolve_ops_proxy(config)
tunnel_node_id = resolve_ops_tunnel_node_id(config)
try:
# 构建 httpx client 参数
@@ -1044,7 +1045,12 @@ class ProviderOpsService:
"timeout": 30.0,
"verify": get_ssl_context(),
}
if proxy:
if tunnel_node_id:
from src.services.proxy_node.tunnel_transport import TunnelTransport
client_kwargs["transport"] = TunnelTransport(tunnel_node_id, timeout=30.0)
logger.debug("使用 tunnel 代理: node_id={}", tunnel_node_id)
elif proxy:
client_kwargs["proxy"] = proxy
logger.debug("使用代理: {}", proxy)

View File

@@ -2,9 +2,6 @@
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,
@@ -12,10 +9,12 @@ from .resolver import (
get_proxy_label,
get_system_proxy_config,
inject_auth_into_proxy_url,
invalidate_proxy_node_cache,
invalidate_system_proxy_cache,
make_proxy_param,
resolve_delegate_config,
resolve_ops_proxy,
resolve_ops_tunnel_node_id,
resolve_proxy_info,
)
from .service import ProxyNodeService, node_to_dict
@@ -25,9 +24,6 @@ __all__ = [
"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",
@@ -36,8 +32,10 @@ __all__ = [
"make_proxy_param",
"get_proxy_label",
"get_system_proxy_config",
"invalidate_proxy_node_cache",
"invalidate_system_proxy_cache",
"resolve_delegate_config",
"resolve_ops_proxy",
"resolve_ops_tunnel_node_id",
"resolve_proxy_info",
]

View File

@@ -1,9 +1,10 @@
"""
ProxyNode 心跳检测调度器
定期检查 proxy_nodes 的 last_heartbeat_at,更新节点状态:
- elapsed > interval * 3 -> unhealthy
- elapsed > interval * 10 -> offline
定期检查 proxy_nodes 的 tunnel 连接状态,更新节点状态:
- tunnel_connected=True -> ONLINE
- tunnel 刚断开 (<60s) -> UNHEALTHY缓冲期避免正在进行的请求被立即切走
- tunnel 断开超过 60s -> OFFLINE
"""
from __future__ import annotations
@@ -56,6 +57,7 @@ class ProxyNodeHealthScheduler:
try:
now = datetime.now(timezone.utc)
# 仅检查非手动节点(手动节点无心跳,始终保持 ONLINE
# 非手动节点均为 tunnel 模式,由 tunnel 连接状态决定
nodes = (
db.query(ProxyNode)
.filter(
@@ -69,19 +71,16 @@ class ProxyNodeHealthScheduler:
changed = 0
for node in nodes:
interval = int(node.heartbeat_interval or 30)
last = node.last_heartbeat_at
if last is None:
new_status = ProxyNodeStatus.OFFLINE
if node.tunnel_connected:
new_status = ProxyNodeStatus.ONLINE
elif node.tunnel_connected_at:
# tunnel 刚断开:给 60s 缓冲期标记为 UNHEALTHY
elapsed = (now - node.tunnel_connected_at).total_seconds()
new_status = (
ProxyNodeStatus.UNHEALTHY if elapsed < 60 else ProxyNodeStatus.OFFLINE
)
else:
elapsed = (now - last).total_seconds()
if elapsed > interval * 10:
new_status = ProxyNodeStatus.OFFLINE
elif elapsed > interval * 3:
new_status = ProxyNodeStatus.UNHEALTHY
else:
new_status = ProxyNodeStatus.ONLINE
new_status = ProxyNodeStatus.OFFLINE
if node.status != new_status:
node.status = new_status

View File

@@ -7,18 +7,13 @@
from __future__ import annotations
import base64
import gzip as _gzip
import hashlib
import hmac as _hmac
import json as _json
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
@@ -81,8 +76,8 @@ def _get_proxy_node_info(node_id: str) -> dict[str, Any] | None:
"name": node.name,
"ip": node.ip,
"port": node.port,
"tls_enabled": bool(node.tls_enabled),
"tls_cert_fingerprint": node.tls_cert_fingerprint,
"tunnel_mode": bool(node.tunnel_mode),
"tunnel_connected": bool(node.tunnel_connected),
}
_proxy_node_cache[node_id] = (value, now + _PROXY_NODE_CACHE_TTL_SECONDS)
@@ -91,40 +86,6 @@ def _get_proxy_node_info(node_id: str) -> dict[str, Any] | None:
db.close()
# ---------------------------------------------------------------------------
# HMAC 签名
# ---------------------------------------------------------------------------
def build_hmac_proxy_url(ip: str, port: int, *, tls_enabled: bool = False) -> str:
"""
构建带 HMAC BasicAuth 的 httpx proxy URL
格式: http(s)://hmac:{timestamp}.{signature}@{ip}:{port}
signature = HMAC-SHA256(PROXY_HMAC_KEY, "{timestamp}") 的 hex
签名不再包含 node_id避免 proxy 重新注册后 Aether 端缓存的旧 node_id
与 proxy 端新 node_id 不一致导致的认证失败窗口。
当 tls_enabled=True 时使用 https:// scheme。
"""
if not config.proxy_hmac_key:
logger.error("PROXY_HMAC_KEY 未配置,无法使用 ProxyNode 代理")
raise ProxyNodeUnavailableError("PROXY_HMAC_KEY 未配置,无法使用 ProxyNode 代理")
timestamp = str(int(time.time()))
payload = timestamp.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)}"
# ---------------------------------------------------------------------------
# 系统默认代理
# ---------------------------------------------------------------------------
@@ -132,6 +93,11 @@ _system_proxy_cache: tuple[dict[str, Any] | None, float] | None = None
_SYSTEM_PROXY_CACHE_TTL = 60.0
def invalidate_proxy_node_cache(node_id: str) -> None:
"""主动清除指定节点的信息缓存tunnel 断开时调用,避免使用过期的连接状态)"""
_proxy_node_cache.pop(node_id, None)
def invalidate_system_proxy_cache() -> None:
"""手动失效系统代理缓存(在删除节点等操作后调用)"""
global _system_proxy_cache
@@ -224,6 +190,32 @@ def make_proxy_param(proxy_url: str | None) -> str | httpx.Proxy | None:
# ---------------------------------------------------------------------------
def _resolve_effective_node(
connector_config: dict[str, Any] | None,
) -> tuple[str | None, dict[str, Any] | None]:
"""
从 connector_config 或系统默认代理中解析有效的 proxy_node_id 及其信息。
Returns:
(node_id, node_info) 或 (None, None)
"""
if connector_config:
node_id = connector_config.get("proxy_node_id")
if isinstance(node_id, str) and node_id.strip():
nid = node_id.strip()
return nid, _get_proxy_node_info(nid)
# 回退:系统默认代理
system_proxy = get_system_proxy_config()
if system_proxy:
node_id_sys = system_proxy.get("node_id")
if isinstance(node_id_sys, str) and node_id_sys.strip():
nid = node_id_sys.strip()
return nid, _get_proxy_node_info(nid)
return None, None
def resolve_ops_proxy(
connector_config: dict[str, Any] | None,
) -> str | httpx.Proxy | None:
@@ -235,37 +227,50 @@ def resolve_ops_proxy(
2. connector_config.proxy旧格式 URL 字符串)
3. 系统默认代理节点
tunnel 模式节点不返回代理 URL由 resolve_ops_tunnel_node_id 处理)。
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
from .tunnel_transport import is_tunnel_node
# 旧格式:直接返回 proxy URL 字符串
node_id, node_info = _resolve_effective_node(connector_config)
if node_id and node_info:
if is_tunnel_node(node_info):
return None # tunnel 模式不使用 proxy URL
try:
url = build_proxy_url({"node_id": node_id, "enabled": True})
return make_proxy_param(url)
except Exception as exc:
logger.warning("解析 proxy_node_id={} 失败,回退到直连: {}", node_id, exc)
return None
# 旧格式:直接返回 proxy URL 字符串
if connector_config:
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 resolve_ops_tunnel_node_id(
connector_config: dict[str, Any] | None,
) -> str | None:
"""
解析 ops connector 的 tunnel 节点 ID
如果配置的代理节点是 tunnel 模式且已连接,返回 node_id。
否则返回 None含系统默认代理回退
"""
from .tunnel_transport import is_tunnel_node
node_id, node_info = _resolve_effective_node(connector_config)
if node_id and node_info and is_tunnel_node(node_info):
return node_id
return None
@@ -401,12 +406,15 @@ def build_proxy_url(proxy_config: dict[str, Any]) -> str | None:
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"],
tls_enabled=node_info.get("tls_enabled", False),
)
# tunnel 模式节点:不构建 proxy URL通过 TunnelTransport 处理)
from .tunnel_transport import is_tunnel_node
if is_tunnel_node(node_info):
return None
# aether-proxy 节点均为 tunnel 模式,不应走到这里
logger.warning("非 tunnel 模式的 aether-proxy 节点不再支持: node_id={}", node_id)
return None
proxy_url: str | None = proxy_config.get("url")
if not proxy_url:
@@ -504,11 +512,10 @@ def compute_proxy_cache_key(proxy_config: dict[str, Any] | None) -> str:
if not proxy_config.get("enabled", True):
return "__no_proxy__"
# ProxyNode 模式:基于 node_id + 时间桶缓存,避免签名随时间变化导致 cache key 爆炸
# ProxyNode 模式:基于 node_id 缓存
node_id = proxy_config.get("node_id")
if isinstance(node_id, str) and node_id.strip():
time_bucket = int(time.time() / 240) # 240s bucket, within 300s HMAC tolerance
return f"proxy_node:{node_id.strip()}:{time_bucket}"
return f"proxy_node:{node_id.strip()}"
# 构建代理 URL 作为缓存键的基础
proxy_url = build_proxy_url(proxy_config)
@@ -520,46 +527,20 @@ def compute_proxy_cache_key(proxy_config: dict[str, Any] | None) -> str:
# ---------------------------------------------------------------------------
# 代发模式 (Delegate API)
# Tunnel 代理配置解析
# ---------------------------------------------------------------------------
def _build_hmac_auth_header() -> str:
"""
构建代发请求的 Authorization 头
格式: Basic base64(hmac:{timestamp}.{signature})
签名算法与 build_hmac_proxy_url 相同(仅使用 timestamp不含 node_id
"""
if not config.proxy_hmac_key:
raise ProxyNodeUnavailableError("PROXY_HMAC_KEY 未配置,无法使用代发模式")
timestamp = str(int(time.time()))
payload = timestamp.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 不支持)
解析 tunnel 代理配置(仅 aether-proxy tunnel 节点支持)
无特定代理时自动回退到系统默认代理。
auth_header 延迟生成:通过 ``fresh_auth_header()`` 闭包在每次请求 / 重试时
获取新鲜的 HMAC 签名,避免长生命周期内时间戳过期
tunnel 模式节点返回 {"tunnel": True, "node_id": str}
调用方应使用 TunnelTransport
Returns:
{"delegate_url": str, "node_id": str, "tls_enabled": bool,
"auth_header": str, # 首次生成的签名(兼容旧调用)
"fresh_auth_header": Callable} # 延迟生成签名的闭包
或 None
{"tunnel": True, "node_id": str} 或 None
"""
effective_config = proxy_config
@@ -571,148 +552,28 @@ def resolve_delegate_config(proxy_config: dict[str, Any] | None) -> dict[str, An
node_id = effective_config.get("node_id")
if not isinstance(node_id, str) or not node_id.strip():
return None # 旧格式 URL 模式不支持代发
return None
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 # 手动节点不支持代发
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"
from .tunnel_transport import is_tunnel_node
# 每次调用生成新鲜签名(避免长连接内时间戳过期)
def _fresh() -> str:
return _build_hmac_auth_header()
if is_tunnel_node(node_info):
return {"tunnel": True, "node_id": node_id}
return {
"delegate_url": delegate_url,
"auth_header": _fresh(), # 立即生成一份,兼容旧调用方
"fresh_auth_header": _fresh,
"node_id": node_id,
"tls_enabled": tls_enabled,
}
return None
# ---------------------------------------------------------------------------
# 代发请求参数构建(消除 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 共用)
元数据通过 HTTP headers 传递X-Delegate-Method/Url/Headers
上游请求体 gzip 压缩后直接作为 HTTP body 发送,大幅减少跨国传输耗时。
Args:
delegate_cfg: resolve_delegate_config 返回的配置
url: 上游实际 URL
headers: 上游请求头
payload: 上游 JSON body可以为 None
timeout: 上游超时秒数
refresh_auth: 为 True 时重新生成 HMAC 签名(用于 retry
"""
auth = (
delegate_cfg["fresh_auth_header"]()
if refresh_auth
else delegate_cfg.get("auth_header") or delegate_cfg["fresh_auth_header"]()
)
# 上游 headers base64 编码
headers_b64 = base64.b64encode(_json.dumps(headers, ensure_ascii=False).encode("utf-8")).decode(
"ascii"
)
# 构建代发请求 headers元数据
delegate_headers: dict[str, str] = {
"Authorization": auth,
"X-Delegate-Method": "POST",
"X-Delegate-Url": url,
"X-Delegate-Headers": headers_b64,
"X-Delegate-Timeout": str(int(timeout)),
}
kwargs: dict[str, Any] = {
"url": delegate_cfg["delegate_url"],
"headers": delegate_headers,
"timeout": httpx.Timeout(timeout + 10),
}
# body gzip 压缩后直接作为 HTTP content
if payload is not None:
body_bytes = _json.dumps(payload, ensure_ascii=False).encode("utf-8")
compressed = _gzip.compress(body_bytes)
kwargs["content"] = compressed
kwargs["headers"]["Content-Encoding"] = "gzip"
kwargs["headers"]["Content-Type"] = _JSON_CT
return kwargs
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,
_delegate_cfg: dict[str, Any] | None = None,
*,
url: str,
headers: dict[str, str],
@@ -721,19 +582,13 @@ def build_post_kwargs(
refresh_auth: bool = False,
) -> dict[str, Any]:
"""
构建上游 POST 请求的 httpx kwargs(自动选择代发或直连模式)
构建上游 POST 请求的 httpx kwargs
返回的 dict 可直接传给 ``http_client.post(**kwargs)``。
``_delegate_cfg`` 和 ``refresh_auth`` 已废弃tunnel 模式下认证由 transport 层处理),
保留仅为兼容现有调用方签名。
"""
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,
@@ -743,7 +598,7 @@ def build_post_kwargs(
def build_stream_kwargs(
delegate_cfg: dict[str, Any] | None,
_delegate_cfg: dict[str, Any] | None = None,
*,
url: str,
headers: dict[str, str],
@@ -751,21 +606,13 @@ def build_stream_kwargs(
timeout: float | None = None,
) -> dict[str, Any]:
"""
构建上游 stream 请求的 httpx kwargs(自动选择代发或直连模式)
构建上游 stream 请求的 httpx kwargs
返回的 dict 可直接传给 ``http_client.stream(**kwargs)``。
当 ``timeout`` 为 None 时由外层 asyncio.wait_for 控制超时。
当 ``timeout`` 为 None直连模式下由外层 asyncio.wait_for 控制超时),
直连分支不设置 timeout代发分支始终携带 timeoutproxy 协议需要)。
``_delegate_cfg`` 已废弃,保留仅为兼容现有调用方签名。
"""
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,

View File

@@ -17,10 +17,9 @@ import httpx
from sqlalchemy.orm import Session
from src.core.exceptions import InvalidRequestException, NotFoundException
from src.models.database import ProxyNode, ProxyNodeStatus, SystemConfig
from src.models.database import Provider, ProviderEndpoint, ProxyNode, ProxyNodeStatus, SystemConfig
from .resolver import (
build_hmac_proxy_url,
inject_auth_into_proxy_url,
invalidate_system_proxy_cache,
make_proxy_param,
@@ -50,14 +49,15 @@ def node_to_dict(node: ProxyNode) -> dict[str, Any]:
"region": node.region,
"status": node.status.value if node.status else None,
"is_manual": bool(node.is_manual),
"tunnel_mode": bool(node.tunnel_mode),
"tunnel_connected": bool(node.tunnel_connected),
"tunnel_connected_at": node.tunnel_connected_at,
"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,
@@ -166,8 +166,8 @@ def _build_test_proxy_url(node: ProxyNode) -> str:
)
return proxy_url
else:
# aether-proxy: 使用 HMAC 认证构建代理 URL
return build_hmac_proxy_url(node.ip, node.port, tls_enabled=bool(node.tls_enabled))
# aether-proxy 节点均为 tunnel 模式,不支持通过代理 URL 测试
raise InvalidRequestException("aether-proxy tunnel 节点不支持代理 URL 连通性测试")
# ---------------------------------------------------------------------------
@@ -187,27 +187,36 @@ class ProxyNodeService:
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,
tunnel_mode: bool = False,
) -> ProxyNode:
"""注册或更新 aether-proxy 节点(按 ip+port upsert"""
"""注册或更新 aether-proxy 节点
tunnel 模式按 name upsertport 固定为 0同 IP 可能有多个实例);
旧模式按 ip+port upsert向后兼容
"""
now = datetime.now(timezone.utc)
node = db.query(ProxyNode).filter(ProxyNode.ip == ip, ProxyNode.port == port).first()
if tunnel_mode:
node = (
db.query(ProxyNode)
.filter(ProxyNode.name == name, ProxyNode.is_manual == False) # noqa: E712
.first()
)
else:
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
node.tunnel_mode = tunnel_mode
if hardware_info is not None:
node.hardware_info = hardware_info
if estimated_max_concurrency is not None:
@@ -232,10 +241,9 @@ class ProxyNodeService:
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,
tunnel_mode=tunnel_mode,
created_at=now,
updated_at=now,
)
@@ -424,6 +432,21 @@ class ProxyNodeService:
sys_cfg.value = None
was_system_proxy = True
# 清理引用该节点的 Provider / ProviderEndpoint 的 proxy 字段(批量 SQL 更新)
cleared_providers = (
db.query(Provider)
.filter(Provider.proxy.isnot(None), Provider.proxy["node_id"].as_string() == node_id)
.update({"proxy": None}, synchronize_session="fetch")
)
cleared_endpoints = (
db.query(ProviderEndpoint)
.filter(
ProviderEndpoint.proxy.isnot(None),
ProviderEndpoint.proxy["node_id"].as_string() == node_id,
)
.update({"proxy": None}, synchronize_session="fetch")
)
node_info = {"proxy_node_ip": node.ip, "proxy_node_port": node.port}
db.delete(node)
db.commit()
@@ -435,6 +458,8 @@ class ProxyNodeService:
"node_id": node_id,
"node_info": node_info,
"cleared_system_proxy": was_system_proxy,
"cleared_providers": cleared_providers,
"cleared_endpoints": cleared_endpoints,
}
@staticmethod

View File

@@ -0,0 +1,334 @@
"""
WebSocket 隧道管理器
管理所有活跃的 aether-proxy tunnel 连接,提供通过隧道发送 HTTP 请求的能力。
每个 proxy node 最多一条 tunnel 连接。
"""
from __future__ import annotations
import asyncio
import json
import time
from typing import TYPE_CHECKING, Any
if TYPE_CHECKING:
from collections.abc import AsyncGenerator
from starlette.websockets import WebSocket, WebSocketState
from src.core.logger import logger
from .tunnel_protocol import Frame, FrameFlags, MsgType
class TunnelConnection:
"""单条 tunnel 连接"""
__slots__ = (
"node_id",
"node_name",
"ws",
"connected_at",
"_pending_streams",
"_write_lock",
"_next_stream_id",
)
def __init__(self, node_id: str, node_name: str, ws: WebSocket) -> None:
self.node_id = node_id
self.node_name = node_name
self.ws = ws
self.connected_at = time.time()
self._pending_streams: dict[int, _StreamState] = {}
self._write_lock = asyncio.Lock()
# Per-connection stream ID 分配器Aether 端使用偶数,从 2 开始)
self._next_stream_id: int = 2
@property
def is_alive(self) -> bool:
return self.ws.client_state == WebSocketState.CONNECTED
async def send_frame(self, frame: Frame) -> None:
async with self._write_lock:
await self.ws.send_bytes(frame.encode())
def create_stream(self, stream_id: int) -> _StreamState:
state = _StreamState(stream_id)
self._pending_streams[stream_id] = state
return state
def get_stream(self, stream_id: int) -> _StreamState | None:
return self._pending_streams.get(stream_id)
def remove_stream(self, stream_id: int) -> None:
self._pending_streams.pop(stream_id, None)
@property
def stream_count(self) -> int:
return len(self._pending_streams)
def has_stream(self, stream_id: int) -> bool:
return stream_id in self._pending_streams
def alloc_stream_id(self, max_streams: int) -> int:
"""分配一个未被占用的偶数 stream_id回绕时跳过飞行中的 ID"""
# 最多尝试 max_streams + 16 次(飞行中的 stream 数量不超过 max_streams
for _ in range(max_streams + 16):
sid = self._next_stream_id
self._next_stream_id += 2
if self._next_stream_id > 0xFFFF_FFFE:
self._next_stream_id = 2
if sid not in self._pending_streams:
return sid
raise TunnelStreamError("stream ID space exhausted")
def cancel_all_streams(self) -> None:
for state in self._pending_streams.values():
state.set_error("tunnel disconnected")
self._pending_streams.clear()
class _StreamState:
"""跟踪单个 stream 的响应状态"""
__slots__ = (
"stream_id",
"status",
"headers",
"_header_event",
"_body_chunks",
"_done_event",
"_error",
)
def __init__(self, stream_id: int) -> None:
self.stream_id = stream_id
self.status: int = 0
self.headers: list[list[str]] = []
self._header_event = asyncio.Event()
self._body_chunks: asyncio.Queue[bytes | None] = asyncio.Queue()
self._done_event = asyncio.Event()
self._error: str | None = None
def set_response_headers(self, status: int, headers: list[list[str]] | dict[str, str]) -> None:
self.status = status
# headers 可能是 [[k, v], ...] (多值) 或 {k: v} (旧格式兼容)
if isinstance(headers, list):
self.headers = headers # type: ignore[assignment]
else:
self.headers = list(headers.items()) # type: ignore[assignment]
self._header_event.set()
def push_body_chunk(self, data: bytes) -> None:
self._body_chunks.put_nowait(data)
def set_done(self) -> None:
self._body_chunks.put_nowait(None) # sentinel
self._done_event.set()
def set_error(self, msg: str) -> None:
self._error = msg
self._header_event.set()
self._body_chunks.put_nowait(None)
self._done_event.set()
async def wait_headers(self, timeout: float = 60.0) -> None:
await asyncio.wait_for(self._header_event.wait(), timeout=timeout)
if self._error:
raise TunnelStreamError(self._error)
async def iter_body(self, chunk_timeout: float = 60.0) -> AsyncGenerator[bytes, None]:
while True:
try:
chunk = await asyncio.wait_for(self._body_chunks.get(), timeout=chunk_timeout)
except asyncio.TimeoutError:
self._error = "body chunk timeout"
self._done_event.set()
raise TunnelStreamError("body chunk timeout")
if chunk is None:
if self._error:
raise TunnelStreamError(self._error)
return
yield chunk
class TunnelStreamError(Exception):
pass
# ---------------------------------------------------------------------------
# 全局 TunnelManager 单例
# ---------------------------------------------------------------------------
class TunnelManager:
"""管理所有活跃的 tunnel 连接"""
# 单条 tunnel 上允许的最大并发 stream 数(超出时拒绝新请求)
MAX_STREAMS_PER_CONN = 2048
def __init__(self) -> None:
self._connections: dict[str, TunnelConnection] = {} # node_id -> conn
@property
def active_count(self) -> int:
return len(self._connections)
def get_connection(self, node_id: str) -> TunnelConnection | None:
conn = self._connections.get(node_id)
if conn and not conn.is_alive:
self._connections.pop(node_id, None)
conn.cancel_all_streams()
return None
return conn
def register(self, conn: TunnelConnection) -> None:
old = self._connections.get(conn.node_id)
if old:
old.cancel_all_streams()
self._connections[conn.node_id] = conn
logger.info("tunnel connected: node_id={}, name={}", conn.node_id, conn.node_name)
def unregister(self, node_id: str) -> None:
conn = self._connections.pop(node_id, None)
if conn:
conn.cancel_all_streams()
logger.info("tunnel disconnected: node_id={}, name={}", node_id, conn.node_name)
def has_tunnel(self, node_id: str) -> bool:
conn = self.get_connection(node_id)
return conn is not None
async def send_request(
self,
node_id: str,
*,
method: str,
url: str,
headers: dict[str, str],
body: bytes | None = None,
timeout: float = 60.0,
) -> _StreamState:
"""
通过 tunnel 发送 HTTP 请求,返回 StreamState 用于读取响应。
"""
conn = self.get_connection(node_id)
if not conn:
raise TunnelStreamError(f"tunnel not connected for node {node_id}")
if conn.stream_count >= self.MAX_STREAMS_PER_CONN:
raise TunnelStreamError(
f"tunnel stream limit reached ({self.MAX_STREAMS_PER_CONN}) for node {node_id}"
)
stream_id = conn.alloc_stream_id(self.MAX_STREAMS_PER_CONN)
stream_state = conn.create_stream(stream_id)
try:
# 发送 REQUEST_HEADERS
meta = json.dumps(
{
"method": method,
"url": url,
"headers": headers,
"timeout": int(timeout),
}
).encode()
await conn.send_frame(Frame(stream_id, MsgType.REQUEST_HEADERS, 0, meta))
# 发送 REQUEST_BODY + END_STREAM
body_data = body or b""
await conn.send_frame(
Frame(stream_id, MsgType.REQUEST_BODY, FrameFlags.END_STREAM, body_data)
)
except Exception:
conn.remove_stream(stream_id)
raise
return stream_state
async def handle_incoming_frame(self, node_id: str, frame: Frame) -> None:
"""处理从 proxy 收到的响应帧"""
conn = self.get_connection(node_id)
if not conn:
return
stream = conn.get_stream(frame.stream_id)
if frame.msg_type == MsgType.RESPONSE_HEADERS:
if not stream:
return
try:
meta = json.loads(frame.payload)
stream.set_response_headers(meta["status"], meta.get("headers", []))
except Exception as e:
stream.set_error(f"invalid response headers: {e}")
elif frame.msg_type == MsgType.RESPONSE_BODY:
if stream:
stream.push_body_chunk(frame.payload)
elif frame.msg_type == MsgType.STREAM_END:
if stream:
stream.set_done()
conn.remove_stream(frame.stream_id)
elif frame.msg_type == MsgType.STREAM_ERROR:
if stream:
msg = frame.payload.decode(errors="replace") if frame.payload else "stream error"
stream.set_error(msg)
conn.remove_stream(frame.stream_id)
elif frame.msg_type == MsgType.HEARTBEAT_DATA:
await self._handle_heartbeat(conn, frame)
elif frame.msg_type == MsgType.PING:
await conn.send_frame(Frame(0, MsgType.PONG, 0, frame.payload))
async def _handle_heartbeat(self, conn: TunnelConnection, frame: Frame) -> None:
"""处理 proxy 上报的心跳数据,更新 DB返回 ACK"""
try:
data = json.loads(frame.payload) if frame.payload else {}
except Exception:
data = {}
def _sync_heartbeat() -> dict[str, Any]:
from src.database import create_session
from src.services.proxy_node.service import ProxyNodeService
db = create_session()
try:
node = ProxyNodeService.heartbeat(
db,
node_id=conn.node_id,
active_connections=data.get("active_connections"),
total_requests=data.get("total_requests"),
avg_latency_ms=data.get("avg_latency_ms"),
)
result: dict[str, Any] = {}
if node.remote_config:
result["remote_config"] = node.remote_config
result["config_version"] = node.config_version or 0
return result
finally:
db.close()
try:
ack = await asyncio.to_thread(_sync_heartbeat)
except Exception as e:
logger.warning("tunnel heartbeat DB update failed: {}", e)
ack = {}
await conn.send_frame(Frame(0, MsgType.HEARTBEAT_ACK, 0, json.dumps(ack).encode()))
# 全局单例
_tunnel_manager: TunnelManager | None = None
def get_tunnel_manager() -> TunnelManager:
global _tunnel_manager
if _tunnel_manager is None:
_tunnel_manager = TunnelManager()
return _tunnel_manager

View File

@@ -0,0 +1,99 @@
"""
WebSocket \u96a7\u9053\u4e8c\u8fdb\u5236\u5e27\u534f\u8bae
\u5e27\u683c\u5f0f:
| stream_id (4B) | msg_type (1B) | flags (1B) | payload_len (4B) | payload (NB) |
\u7528\u4e8e Aether \u4e0e aether-proxy \u4e4b\u95f4\u7684 WebSocket \u96a7\u9053\u591a\u8def\u590d\u7528\u901a\u4fe1\u3002
"""
import struct
from enum import IntEnum
from typing import Self
HEADER_SIZE = 10 # 4 + 1 + 1 + 4 bytes
class MsgType(IntEnum):
"""\u6d88\u606f\u7c7b\u578b"""
REQUEST_HEADERS = 0x01 # Aether -> Proxy: \u8bf7\u6c42\u5143\u6570\u636e (JSON)
REQUEST_BODY = 0x02 # Aether -> Proxy: \u8bf7\u6c42\u4f53
RESPONSE_HEADERS = 0x03 # Proxy -> Aether: \u54cd\u5e94\u72b6\u6001\u7801 + headers (JSON)
RESPONSE_BODY = 0x04 # Proxy -> Aether: \u54cd\u5e94\u4f53\uff08\u6d41\u5f0f\u5206\u5757\uff09
STREAM_END = 0x05 # \u53cc\u5411: \u6d41\u7ed3\u675f
STREAM_ERROR = 0x06 # \u53cc\u5411: \u6d41\u9519\u8bef
PING = 0x10 # \u53cc\u5411: \u5fc3\u8df3 (stream_id=0)
PONG = 0x11 # \u53cc\u5411: \u5fc3\u8df3\u54cd\u5e94 (stream_id=0)
GOAWAY = 0x12 # \u53cc\u5411: \u4f18\u96c5\u5173\u95ed (stream_id=0)
HEARTBEAT_DATA = 0x13 # Proxy -> Aether: \u6307\u6807\u4e0a\u62a5
HEARTBEAT_ACK = 0x14 # Aether -> Proxy: \u5fc3\u8df3\u786e\u8ba4 + \u8fdc\u7a0b\u914d\u7f6e
class FrameFlags:
"""\u5e27\u6807\u5fd7\u4f4d"""
END_STREAM = 0x01
GZIP_COMPRESSED = 0x02
class Frame:
"""WebSocket \u96a7\u9053\u5e27"""
__slots__ = ("stream_id", "msg_type", "flags", "payload")
def __init__(
self,
stream_id: int,
msg_type: MsgType,
flags: int = 0,
payload: bytes = b"",
) -> None:
self.stream_id = stream_id
self.msg_type = msg_type
self.flags = flags
self.payload = payload
def encode(self) -> bytes:
header = struct.pack(
"!IBBI",
self.stream_id,
self.msg_type,
self.flags,
len(self.payload),
)
return header + self.payload
@classmethod
def decode(cls, data: bytes) -> Self:
if len(data) < HEADER_SIZE:
raise ValueError(
f"\u5e27\u6570\u636e\u592a\u77ed: \u9700\u8981 {HEADER_SIZE} \u5b57\u8282, \u5b9e\u9645 {len(data)}"
)
stream_id, msg_type_raw, flags, payload_len = struct.unpack("!IBBI", data[:HEADER_SIZE])
expected_total = HEADER_SIZE + payload_len
if len(data) < expected_total:
raise ValueError(
f"\u5e27\u6570\u636e\u4e0d\u5b8c\u6574: \u9700\u8981 {expected_total} \u5b57\u8282, \u5b9e\u9645 {len(data)}"
)
try:
msg_type = MsgType(msg_type_raw)
except ValueError:
raise ValueError(f"\u672a\u77e5\u6d88\u606f\u7c7b\u578b: 0x{msg_type_raw:02x}")
payload = data[HEADER_SIZE:expected_total]
return cls(stream_id, msg_type, flags, payload)
@property
def is_end_stream(self) -> bool:
return bool(self.flags & FrameFlags.END_STREAM)
@property
def is_gzip(self) -> bool:
return bool(self.flags & FrameFlags.GZIP_COMPRESSED)
def __repr__(self) -> str:
return (
f"Frame(stream={self.stream_id}, type={self.msg_type.name}, "
f"flags=0x{self.flags:02x}, payload_len={len(self.payload)})"
)

View File

@@ -0,0 +1,133 @@
"""
Tunnel httpx Transport
自定义 httpx AsyncBaseTransport将 HTTP 请求通过 WebSocket tunnel 发送到 aether-proxy。
对 handler 层完全透明 -- 只需在创建 httpx.AsyncClient 时使用此 transport。
"""
from __future__ import annotations
import asyncio
from typing import TYPE_CHECKING, Any
if TYPE_CHECKING:
from collections.abc import AsyncGenerator
import httpx
from .tunnel_manager import TunnelManager, TunnelStreamError, _StreamState, get_tunnel_manager
_HOP_BY_HOP_HEADERS = frozenset(
{
"host",
"transfer-encoding",
"content-length",
"connection",
"upgrade",
"keep-alive",
"proxy-authorization",
"proxy-connection",
"te",
"trailer",
}
)
# bytes 版本,用于直接比较 httpx raw headerskey 已经是小写 bytes
_HOP_BY_HOP_HEADERS_BYTES = frozenset(h.encode("ascii") for h in _HOP_BY_HOP_HEADERS)
class TunnelTransport(httpx.AsyncBaseTransport):
"""通过 WebSocket tunnel 发送请求的 httpx transport"""
def __init__(self, node_id: str, timeout: float = 60.0) -> None:
self._node_id = node_id
self._timeout = timeout
async def handle_async_request(self, request: httpx.Request) -> httpx.Response:
manager = get_tunnel_manager()
# 构建 headers dict跳过 hop-by-hop 和 httpx 内部 headers
# request.headers.raw 返回 (bytes, bytes) 元组key 已经是小写
headers: dict[str, str] = {}
for key, value in request.headers.raw:
if key not in _HOP_BY_HOP_HEADERS_BYTES:
headers[key.decode("latin-1")] = value.decode("latin-1")
# 读取 body -- request.content 在 json= 传参时已由 httpx 序列化好;
# 对 stream 类型的 request 需要先 read() 才能拿到完整 content。
body = request.content or await request.aread() or None
stream_state: _StreamState | None = None
try:
stream_state = await manager.send_request(
self._node_id,
method=request.method,
url=str(request.url),
headers=headers,
body=body,
timeout=self._timeout,
)
# 等待响应头
await stream_state.wait_headers(timeout=self._timeout)
# 构建 httpx.Response流式 body
resp_headers = httpx.Headers(stream_state.headers)
return httpx.Response(
status_code=stream_state.status,
headers=resp_headers,
stream=TunnelResponseStream(
manager, self._node_id, stream_state, timeout=self._timeout
),
)
except TunnelStreamError as e:
self._cleanup_stream(manager, stream_state)
# 区分连接阶段和响应阶段的错误
if stream_state and stream_state.status > 0:
raise httpx.ReadError(str(e)) from e
raise httpx.ConnectError(str(e)) from e
except asyncio.TimeoutError:
self._cleanup_stream(manager, stream_state)
raise httpx.ReadTimeout("tunnel request timeout") from None
def _cleanup_stream(self, manager: TunnelManager, stream_state: _StreamState | None) -> None:
if stream_state is None:
return
conn = manager.get_connection(self._node_id)
if conn:
conn.remove_stream(stream_state.stream_id)
class TunnelResponseStream(httpx.AsyncByteStream):
"""将 tunnel stream 的 body chunks 包装为 httpx AsyncByteStream"""
def __init__(
self,
manager: TunnelManager,
node_id: str,
stream_state: _StreamState,
timeout: float = 60.0,
) -> None:
self._manager = manager
self._node_id = node_id
self._stream_state = stream_state
self._timeout = timeout
async def __aiter__(self) -> AsyncGenerator[bytes, None]:
async for chunk in self._stream_state.iter_body(chunk_timeout=self._timeout):
yield chunk
async def aclose(self) -> None:
# 确保 stream 从 connection 的 pending 列表中移除,防止内存泄漏
conn = self._manager.get_connection(self._node_id)
if conn:
conn.remove_stream(self._stream_state.stream_id)
def is_tunnel_node(node_info: dict[str, Any] | None) -> bool:
"""检查节点是否为 tunnel 模式且已连接"""
if not node_info:
return False
return bool(node_info.get("tunnel_mode")) and bool(node_info.get("tunnel_connected"))