feat(tunnel): 引入 aether-hub 帧路由器,支持多 worker 共享 tunnel 连接

新增 Rust 实现的 aether-hub 服务,作为 Docker 容器内部 WebSocket 帧路由器,
解决多 Gunicorn worker 进程间 tunnel 连接隔离问题。

主要改动:
- 新增 aether-hub Rust 项目,实现 proxy/worker 双向帧路由与 stream_id 重映射
- 新增 HubConnectionManager/HubTunnelTransport,worker 通过 Hub 转发 tunnel 帧
- 新增 create_tunnel_transport 工厂函数,按运行环境自动选择 Hub 或直连模式
- 新增 NODE_STATUS 广播机制,Hub 实时通知所有 worker 节点连接状态变化
- CI/CD 新增 build-hub job,Dockerfile 集成 Hub 二进制,deploy.sh 适配 Hub 构建
- 默认 GUNICORN_WORKERS 从 4 降为 2
This commit is contained in:
fawney19
2026-03-02 02:43:14 +08:00
parent 97d42703da
commit 039a18c243
30 changed files with 3728 additions and 75 deletions

View File

@@ -214,9 +214,9 @@ async def _get_acw_cookie(
"verify": get_ssl_context(),
}
if tunnel_node_id:
from src.services.proxy_node.tunnel_transport import TunnelTransport
from src.services.proxy_node.tunnel_transport import create_tunnel_transport
client_kwargs["transport"] = TunnelTransport(tunnel_node_id, timeout=timeout)
client_kwargs["transport"] = create_tunnel_transport(tunnel_node_id, timeout=timeout)
elif proxy:
client_kwargs["proxy"] = proxy
logger.debug(f"获取 acw_sc__v2 Cookie 使用代理: {proxy}")

View File

@@ -127,9 +127,9 @@ class ProviderConnector(ABC):
"""
transport = None
if self._tunnel_node_id:
from src.services.proxy_node.tunnel_transport import TunnelTransport
from src.services.proxy_node.tunnel_transport import create_tunnel_transport
transport = TunnelTransport(self._tunnel_node_id, timeout=self._timeout)
transport = create_tunnel_transport(self._tunnel_node_id, timeout=self._timeout)
elif self._proxy:
transport = httpx.AsyncHTTPTransport(proxy=self._proxy)

View File

@@ -197,9 +197,9 @@ class NekoCodeArchitecture(ProviderArchitecture):
proxy, tunnel_node_id = resolve_ops_proxy_config(config)
if tunnel_node_id:
from src.services.proxy_node.tunnel_transport import TunnelTransport
from src.services.proxy_node.tunnel_transport import create_tunnel_transport
client_kwargs["transport"] = TunnelTransport(tunnel_node_id, timeout=10.0)
client_kwargs["transport"] = create_tunnel_transport(tunnel_node_id, timeout=10.0)
elif proxy:
client_kwargs["proxy"] = proxy

View File

@@ -106,9 +106,9 @@ class _Sub2ApiTokenMixin:
"""获取不带 auth hook 的裸 HTTP 客户端(用于登录/刷新 token"""
transport = None
if self._tunnel_node_id:
from src.services.proxy_node.tunnel_transport import TunnelTransport
from src.services.proxy_node.tunnel_transport import create_tunnel_transport
transport = TunnelTransport(self._tunnel_node_id, timeout=self._timeout)
transport = create_tunnel_transport(self._tunnel_node_id, timeout=self._timeout)
elif self._proxy:
transport = httpx.AsyncHTTPTransport(proxy=self._proxy)
async with httpx.AsyncClient(
@@ -488,9 +488,9 @@ class Sub2ApiArchitecture(ProviderArchitecture):
"verify": get_ssl_context(),
}
if tunnel_node_id:
from src.services.proxy_node.tunnel_transport import TunnelTransport
from src.services.proxy_node.tunnel_transport import create_tunnel_transport
client_kwargs["transport"] = TunnelTransport(tunnel_node_id, timeout=30.0)
client_kwargs["transport"] = create_tunnel_transport(tunnel_node_id, timeout=30.0)
elif proxy:
client_kwargs["proxy"] = proxy

View File

@@ -245,9 +245,9 @@ class YesCodeArchitecture(ProviderArchitecture):
"verify": get_ssl_context(),
}
if tunnel_node_id:
from src.services.proxy_node.tunnel_transport import TunnelTransport
from src.services.proxy_node.tunnel_transport import create_tunnel_transport
client_kwargs["transport"] = TunnelTransport(tunnel_node_id, timeout=10.0)
client_kwargs["transport"] = create_tunnel_transport(tunnel_node_id, timeout=10.0)
elif proxy:
client_kwargs["proxy"] = proxy

View File

@@ -1045,9 +1045,9 @@ class ProviderOpsService:
"verify": get_ssl_context(),
}
if tunnel_node_id:
from src.services.proxy_node.tunnel_transport import TunnelTransport
from src.services.proxy_node.tunnel_transport import create_tunnel_transport
client_kwargs["transport"] = TunnelTransport(tunnel_node_id, timeout=30.0)
client_kwargs["transport"] = create_tunnel_transport(tunnel_node_id, timeout=30.0)
logger.debug("使用 tunnel 代理: node_id={}", tunnel_node_id)
elif proxy:
client_kwargs["proxy"] = proxy

View File

@@ -0,0 +1,70 @@
"""
Tunnel Hub 配置
控制 worker 是否通过 aether-hub 转发 tunnel 帧。
设计约束:
- Hub 作为 Docker 内部固定服务运行
- 不对外暴露运行时配置项(不依赖 TUNNEL_HUB_* 环境变量)
"""
from __future__ import annotations
import os
from dataclasses import dataclass
_DOCKER_HUB_URL = "ws://127.0.0.1:8085"
_DOCKER_HUB_CONNECT_TIMEOUT_SECONDS = 5.0
_DOCKER_HUB_PING_INTERVAL_SECONDS = 15.0
_DOCKER_HUB_SEND_TIMEOUT_SECONDS = 10.0
_DOCKER_HUB_MAX_STREAMS = 2048
_DOCKER_HUB_MAX_FRAME_SIZE = 64 * 1024 * 1024
@dataclass(frozen=True)
class HubConfig:
enabled: bool
url: str
connect_timeout_seconds: float
ping_interval_seconds: float
send_timeout_seconds: float
max_streams: int
max_frame_size: int
@property
def worker_ws_url(self) -> str:
return f"{self.url.rstrip('/')}/worker"
_hub_config: HubConfig | None = None
def _is_docker_runtime() -> bool:
if os.getenv("DOCKER_CONTAINER", "").strip().lower() == "true":
return True
return os.path.exists("/.dockerenv")
def get_hub_config() -> HubConfig:
"""读取 Hub 配置(进程内缓存)。"""
global _hub_config
if _hub_config is not None:
return _hub_config
docker_runtime = _is_docker_runtime()
_hub_config = HubConfig(
enabled=docker_runtime,
url=_DOCKER_HUB_URL,
connect_timeout_seconds=_DOCKER_HUB_CONNECT_TIMEOUT_SECONDS,
ping_interval_seconds=_DOCKER_HUB_PING_INTERVAL_SECONDS,
send_timeout_seconds=_DOCKER_HUB_SEND_TIMEOUT_SECONDS,
max_streams=_DOCKER_HUB_MAX_STREAMS,
max_frame_size=_DOCKER_HUB_MAX_FRAME_SIZE,
)
return _hub_config
def reset_hub_config_cache() -> None:
"""测试或热更新场景下清理配置缓存。"""
global _hub_config
_hub_config = None

View File

@@ -0,0 +1,639 @@
"""
Hub 模式 tunnel transport
Worker 通过单条到 aether-hub 的 WebSocket 长连接转发 tunnel 帧。
"""
from __future__ import annotations
import asyncio
import gzip
import json
from datetime import datetime, timezone
from typing import TYPE_CHECKING, Any
import aiohttp
import httpx
from aiohttp import WSMsgType
from src.core.logger import logger
from .hub_config import HubConfig, get_hub_config
from .tunnel_manager import TunnelStreamError, _StreamState
from .tunnel_protocol import Frame, FrameFlags, MsgType
if TYPE_CHECKING:
from collections.abc import AsyncGenerator, Coroutine
_TUNNEL_COMPRESS_MIN_SIZE = 512
_RECONNECT_DELAYS_SECONDS: tuple[float, ...] = (0.0, 0.05, 0.1, 0.2, 0.5, 1.0, 2.0, 5.0)
_HOP_BY_HOP_HEADERS = frozenset(
{
"host",
"transfer-encoding",
"content-length",
"connection",
"upgrade",
"keep-alive",
"proxy-authorization",
"proxy-connection",
"te",
"trailer",
}
)
_HOP_BY_HOP_HEADERS_BYTES = frozenset(h.encode("ascii") for h in _HOP_BY_HOP_HEADERS)
class HubConnectionManager:
"""Worker 进程级 Hub 连接管理器(单例)。"""
def __init__(self, config: HubConfig | None = None) -> None:
self._config = config or get_hub_config()
self._session: aiohttp.ClientSession | None = None
self._ws: aiohttp.ClientWebSocketResponse | None = None
self._connect_lock = asyncio.Lock()
self._write_lock = asyncio.Lock()
self._next_stream_id = 2
self._pending_streams: dict[int, _StreamState] = {}
self._reader_task: asyncio.Task[None] | None = None
self._ping_task: asyncio.Task[None] | None = None
self._reconnect_task: asyncio.Task[None] | None = None
self._background_tasks: set[asyncio.Task[None]] = set()
self._closing = False
@property
def is_connected(self) -> bool:
ws = self._ws
return ws is not None and not ws.closed
def _background(self, coro: Coroutine[Any, Any, None]) -> None:
task = asyncio.create_task(coro)
self._background_tasks.add(task)
task.add_done_callback(self._background_tasks.discard)
async def ensure_connected(self) -> None:
if self._closing:
raise TunnelStreamError("hub connection manager is shutting down")
if self.is_connected:
return
async with self._connect_lock:
if self._closing:
raise TunnelStreamError("hub connection manager is shutting down")
if self.is_connected:
return
try:
await self._connect_once()
except Exception as e:
self._start_reconnect_loop()
raise TunnelStreamError(f"failed to connect hub worker channel: {e}") from e
async def _ensure_session(self) -> aiohttp.ClientSession:
if self._session is None or self._session.closed:
self._session = aiohttp.ClientSession()
return self._session
async def _connect_once(self) -> None:
session = await self._ensure_session()
ws = await session.ws_connect(
self._config.worker_ws_url,
timeout=self._config.connect_timeout_seconds,
autoping=False,
heartbeat=None,
max_msg_size=self._config.max_frame_size,
)
old_ws = self._ws
self._ws = ws
if old_ws is not None and not old_ws.closed:
try:
await old_ws.close()
except Exception:
pass
if self._reader_task is not None:
self._reader_task.cancel()
if self._ping_task is not None:
self._ping_task.cancel()
self._reader_task = asyncio.create_task(self._reader_loop(ws))
self._ping_task = asyncio.create_task(self._ping_loop(ws))
logger.info("Hub worker channel connected: {}", self._config.worker_ws_url)
def _start_reconnect_loop(self) -> None:
if self._closing:
return
if self._reconnect_task is not None and not self._reconnect_task.done():
return
self._reconnect_task = asyncio.create_task(self._reconnect_loop())
async def _reconnect_loop(self) -> None:
attempt = 0
while not self._closing and not self.is_connected:
delay = _RECONNECT_DELAYS_SECONDS[min(attempt, len(_RECONNECT_DELAYS_SECONDS) - 1)]
if delay > 0:
await asyncio.sleep(delay)
try:
async with self._connect_lock:
if self._closing or self.is_connected:
break
await self._connect_once()
if self.is_connected:
logger.info("Hub worker channel reconnected")
break
except Exception as e:
attempt += 1
logger.debug("Hub reconnect attempt {} failed: {}", attempt, e)
async def _handle_disconnect(
self,
reason: str,
*,
ws: aiohttp.ClientWebSocketResponse | None = None,
) -> None:
current: aiohttp.ClientWebSocketResponse | None = None
async with self._connect_lock:
if self._ws is None:
return
if ws is not None and self._ws is not ws:
return
current = self._ws
self._ws = None
if current is not None and not current.closed:
try:
await current.close()
except Exception:
pass
if self._pending_streams:
for state in self._pending_streams.values():
state.set_error("hub disconnected")
self._pending_streams.clear()
if not self._closing:
logger.warning("Hub worker channel disconnected: {}", reason)
self._start_reconnect_loop()
async def _send_frame(self, frame: Frame) -> None:
ws = self._ws
if ws is None or ws.closed:
raise TunnelStreamError("hub not connected")
try:
async with asyncio.timeout(self._config.send_timeout_seconds):
async with self._write_lock:
await ws.send_bytes(frame.encode())
except TimeoutError as e:
await self._handle_disconnect("send timeout", ws=ws)
raise TunnelStreamError("hub frame send timeout") from e
except Exception as e:
await self._handle_disconnect(f"send failed: {e}", ws=ws)
raise TunnelStreamError(f"hub frame send failed: {e}") from e
async def _reader_loop(self, ws: aiohttp.ClientWebSocketResponse) -> None:
try:
while not self._closing:
msg = await ws.receive()
if msg.type == WSMsgType.BINARY:
raw = msg.data
if isinstance(raw, memoryview):
raw = raw.tobytes()
elif isinstance(raw, bytearray):
raw = bytes(raw)
if not isinstance(raw, bytes):
continue
try:
frame = Frame.decode(raw)
except Exception as e:
logger.debug("invalid frame from hub: {}", e)
continue
await self._handle_incoming_frame(frame)
continue
if msg.type == WSMsgType.CLOSE or msg.type == WSMsgType.CLOSED:
break
if msg.type == WSMsgType.ERROR:
logger.debug("hub ws reader error: {}", ws.exception())
break
if msg.type == WSMsgType.PING:
payload = msg.data if isinstance(msg.data, bytes) else b""
self._background(self._send_pong(payload))
continue
# TEXT / PONG / 其他类型直接忽略
except asyncio.CancelledError:
return
except Exception as e:
logger.debug("hub reader loop aborted: {}", e)
finally:
await self._handle_disconnect("reader ended", ws=ws)
async def _ping_loop(self, ws: aiohttp.ClientWebSocketResponse) -> None:
try:
while not self._closing:
await asyncio.sleep(self._config.ping_interval_seconds)
if self._ws is not ws or ws.closed:
break
try:
await self._send_frame(Frame(0, MsgType.PING, 0, b""))
except TunnelStreamError:
break
except asyncio.CancelledError:
return
async def _send_pong(self, payload: bytes) -> None:
try:
await self._send_frame(Frame(0, MsgType.PONG, 0, payload))
except TunnelStreamError:
pass
async def _handle_incoming_frame(self, frame: Frame) -> None:
match frame.msg_type:
# -- stream-level frames --
case MsgType.RESPONSE_HEADERS:
stream = self._pending_streams.get(frame.stream_id)
if not stream:
return
try:
payload = _decompress_frame_payload(frame)
meta = json.loads(payload)
stream.set_response_headers(meta["status"], meta.get("headers", []))
except Exception as e:
stream.set_error(f"invalid response headers: {e}")
self._pending_streams.pop(frame.stream_id, None)
case MsgType.RESPONSE_BODY:
stream = self._pending_streams.get(frame.stream_id)
if stream:
stream.push_body_chunk(_decompress_frame_payload(frame))
case MsgType.STREAM_END:
stream = self._pending_streams.pop(frame.stream_id, None)
if stream:
stream.set_done()
case MsgType.STREAM_ERROR:
stream = self._pending_streams.pop(frame.stream_id, None)
if stream:
message = (
frame.payload.decode(errors="replace") if frame.payload else "stream error"
)
stream.set_error(message)
# -- connection-level frames --
case MsgType.PING:
self._background(self._send_pong(frame.payload))
case MsgType.PONG:
pass
case MsgType.GOAWAY:
await self._handle_disconnect("received GOAWAY")
case MsgType.HEARTBEAT_DATA:
self._background(self._handle_heartbeat(frame))
case MsgType.HEARTBEAT_ACK:
pass
case MsgType.NODE_STATUS:
self._background(self._handle_node_status(frame.payload))
async def _handle_heartbeat(self, frame: Frame) -> None:
try:
data = json.loads(frame.payload) if frame.payload else {}
except Exception:
data = {}
node_id = str(data.get("node_id") or "").strip()
def _sync_heartbeat() -> dict[str, object]:
from src.database import create_session
from src.services.proxy_node.service import ProxyNodeService
if not node_id:
return {}
db = create_session()
try:
node = ProxyNodeService.heartbeat(
db,
node_id=node_id,
active_connections=data.get("active_connections"),
total_requests=data.get("total_requests"),
avg_latency_ms=data.get("avg_latency_ms"),
failed_requests=data.get("failed_requests"),
dns_failures=data.get("dns_failures"),
stream_errors=data.get("stream_errors"),
)
result: dict[str, object] = {}
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("hub heartbeat DB update failed: {}", e)
ack = {}
try:
await self._send_frame(
Frame(
frame.stream_id,
MsgType.HEARTBEAT_ACK,
0,
json.dumps(ack, ensure_ascii=False).encode("utf-8"),
)
)
except TunnelStreamError:
logger.debug("hub heartbeat ACK send failed")
async def _handle_node_status(self, payload: bytes) -> None:
try:
data = json.loads(payload) if payload else {}
except Exception:
return
node_id = str(data.get("node_id") or "").strip()
if not node_id:
return
connected = bool(data.get("connected"))
conn_count = int(data.get("conn_count") or 0)
# 所有 worker 都需要立即失效本地缓存,保证请求路由正确
try:
from src.services.proxy_node.resolver import invalidate_proxy_node_cache
invalidate_proxy_node_cache(node_id)
except Exception:
pass
# 使用 Redis SETNX 去重:同一次 NODE_STATUS 广播只有一个 worker 执行 DB 写入,
# 避免 N 个 worker 并发写同一行并产生 N 条重复事件记录。
dedup_key = f"hub:node_status:{node_id}:{connected}:{conn_count}"
try:
from src.clients import get_redis_client
redis = await get_redis_client()
if redis:
acquired = await redis.set(dedup_key, "1", ex=10, nx=True)
if not acquired:
return
except Exception:
# Redis 不可用时不去重,允许重复写入(幂等)
pass
def _sync_update() -> None:
from src.database import create_session
from src.models.database import ProxyNode, ProxyNodeEvent, ProxyNodeStatus
db = create_session()
try:
node = db.query(ProxyNode).filter(ProxyNode.id == node_id).first()
if not node:
return
now = datetime.now(timezone.utc)
node.tunnel_connected = connected
if connected:
node.tunnel_connected_at = now
node.status = ProxyNodeStatus.ONLINE if connected else ProxyNodeStatus.OFFLINE
node.updated_at = now
event = ProxyNodeEvent(
node_id=node_id,
event_type="connected" if connected else "disconnected",
detail=f"[hub_node_status] conn_count={conn_count}",
)
db.add(event)
db.commit()
except Exception:
try:
db.rollback()
except Exception:
pass
raise
finally:
db.close()
try:
await asyncio.to_thread(_sync_update)
except Exception as e:
logger.warning("hub NODE_STATUS DB update failed: node_id={}, error={}", node_id, e)
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:
await self.ensure_connected()
if len(self._pending_streams) >= self._config.max_streams:
raise TunnelStreamError(
f"hub stream limit reached ({self._config.max_streams}) for node {node_id}"
)
stream_id = self._alloc_stream_id()
stream_state = _StreamState(stream_id)
self._pending_streams[stream_id] = stream_state
try:
meta = json.dumps(
{
"node_id": node_id,
"method": method,
"url": url,
"headers": headers,
"timeout": timeout,
},
ensure_ascii=False,
separators=(",", ":"),
).encode("utf-8")
meta_payload, meta_flags = _compress_frame_payload(meta)
await self._send_frame(
Frame(stream_id, MsgType.REQUEST_HEADERS, meta_flags, meta_payload)
)
body_data = body or b""
if body_data:
body_payload, body_flags = _compress_frame_payload(body_data)
else:
body_payload, body_flags = body_data, 0
body_flags |= FrameFlags.END_STREAM
await self._send_frame(Frame(stream_id, MsgType.REQUEST_BODY, body_flags, body_payload))
except Exception:
self._pending_streams.pop(stream_id, None)
raise
return stream_state
def remove_stream(self, stream_id: int) -> None:
self._pending_streams.pop(stream_id, None)
def _alloc_stream_id(self) -> int:
sid = self._next_stream_id
self._next_stream_id = sid + 2 if sid < 0xFFFF_FFFE else 2
return sid
async def shutdown(self) -> None:
self._closing = True
if self._reconnect_task is not None:
self._reconnect_task.cancel()
if self._reader_task is not None:
self._reader_task.cancel()
if self._ping_task is not None:
self._ping_task.cancel()
tasks = list(self._background_tasks)
for task in tasks:
task.cancel()
if tasks:
await asyncio.gather(*tasks, return_exceptions=True)
if self._ws is not None and not self._ws.closed:
try:
await self._ws.close()
except Exception:
pass
self._ws = None
if self._session is not None and not self._session.closed:
try:
await self._session.close()
except Exception:
pass
self._session = None
if self._pending_streams:
for state in self._pending_streams.values():
state.set_error("hub connection manager shutdown")
self._pending_streams.clear()
logger.info("Hub connection manager shutdown completed")
class HubTunnelTransport(httpx.AsyncBaseTransport):
"""通过 aether-hub 转发请求的 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_hub_connection_manager()
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 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)
return httpx.Response(
status_code=stream_state.status,
headers=httpx.Headers(stream_state.headers),
stream=HubResponseStream(manager, 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("hub tunnel request timeout") from None
def _cleanup_stream(
self,
manager: HubConnectionManager,
stream_state: _StreamState | None,
) -> None:
if stream_state is None:
return
manager.remove_stream(stream_state.stream_id)
class HubResponseStream(httpx.AsyncByteStream):
def __init__(
self,
manager: HubConnectionManager,
stream_state: _StreamState,
timeout: float = 60.0,
) -> None:
self._manager = manager
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:
self._manager.remove_stream(self._stream_state.stream_id)
def _compress_frame_payload(data: bytes) -> tuple[bytes, int]:
if len(data) >= _TUNNEL_COMPRESS_MIN_SIZE:
compressed = gzip.compress(data, compresslevel=6)
if len(compressed) < len(data):
return compressed, FrameFlags.GZIP_COMPRESSED
return data, 0
def _decompress_frame_payload(frame: Frame) -> bytes:
if frame.is_gzip:
return gzip.decompress(frame.payload)
return frame.payload
_hub_connection_manager: HubConnectionManager | None = None
def get_hub_connection_manager() -> HubConnectionManager:
global _hub_connection_manager
if _hub_connection_manager is None:
_hub_connection_manager = HubConnectionManager()
return _hub_connection_manager
async def shutdown_hub_connection_manager() -> None:
global _hub_connection_manager
if _hub_connection_manager is None:
return
await _hub_connection_manager.shutdown()
_hub_connection_manager = None

View File

@@ -33,6 +33,51 @@ _TUNNEL_LOCAL_MISS_LOG_COOLDOWN_SECONDS = 30.0
_tunnel_local_miss_log_next_at: dict[str, float] = {}
def _is_hub_mode_enabled() -> bool:
from src.services.proxy_node.hub_config import get_hub_config
return get_hub_config().enabled
def _build_tunnel_local_miss_message(node_id: str) -> str:
"""构建“当前 worker 无本地 tunnel”的用户可读错误信息。"""
return (
f"代理节点 {node_id} 当前 worker 无 tunnel 连接pid={os.getpid()})。"
"这通常发生在多 worker 部署WEB_CONCURRENCY/GUNICORN_WORKERS > 1时。"
"请设置 GUNICORN_WORKERS=1或 WEB_CONCURRENCY=1"
"或确保每个 worker 都建立该节点的 tunnel 连接。"
)
def _is_tunnel_local_miss(node_id: str) -> bool:
"""判断节点是否“全局在线但当前 worker 无本地 tunnel 连接”。
仅在错误路径调用build_proxy_url 解析失败时),用于提供更准确的报错信息。
"""
if _is_hub_mode_enabled():
return False
from src.database import create_session
from src.models.database import ProxyNode, ProxyNodeStatus
from src.services.proxy_node.tunnel_manager import get_tunnel_manager
db = create_session()
try:
node = db.query(ProxyNode).filter(ProxyNode.id == node_id).first()
if not node:
return False
if node.is_manual or not node.tunnel_mode:
return False
if node.status != ProxyNodeStatus.ONLINE:
return False
manager = get_tunnel_manager()
return not manager.has_tunnel(node_id)
except Exception:
return False
finally:
db.close()
def _get_proxy_node_info(node_id: str) -> dict[str, Any] | None:
"""
读取 ProxyNode 信息(带内存 TTL 缓存)
@@ -71,32 +116,41 @@ def _get_proxy_node_info(node_id: str) -> dict[str, Any] | None:
_proxy_node_cache[node_id] = (None, now + _PROXY_NODE_CACHE_NEGATIVE_TTL_SECONDS)
return None
# tunnel 模式节点:以 TunnelManager 内存中的实际连接状态为准,
# 而非依赖 DB 的 status/tunnel_connected 字段(可能因竞态不同步)。
# tunnel 模式节点:
# - Hub 模式:信任 DB 的 tunnel_connected/status由 Hub 广播统一维护)
# - 非 Hub 模式:以当前 worker 本地 TunnelManager 状态为准
if node.tunnel_mode and not node.is_manual:
from src.services.proxy_node.tunnel_manager import get_tunnel_manager
if _is_hub_mode_enabled():
if node.status != ProxyNodeStatus.ONLINE or not bool(node.tunnel_connected):
_proxy_node_cache[node_id] = (
None,
now + _PROXY_NODE_CACHE_NEGATIVE_TTL_SECONDS,
)
return None
else:
from src.services.proxy_node.tunnel_manager import get_tunnel_manager
manager = get_tunnel_manager()
if not manager.has_tunnel(node_id):
# 多 worker 部署时DB 可能显示 ONLINE其他 worker 有 tunnel
# 但当前 worker 无本地连接,请求仍不可用。记录限频告警便于定位。
if now >= _tunnel_local_miss_log_next_at.get(node_id, 0.0):
_tunnel_local_miss_log_next_at[node_id] = (
now + _TUNNEL_LOCAL_MISS_LOG_COOLDOWN_SECONDS
manager = get_tunnel_manager()
if not manager.has_tunnel(node_id):
# 多 worker 部署时DB 可能显示 ONLINE其他 worker 有 tunnel
# 但当前 worker 无本地连接,请求仍不可用。记录限频告警便于定位。
if now >= _tunnel_local_miss_log_next_at.get(node_id, 0.0):
_tunnel_local_miss_log_next_at[node_id] = (
now + _TUNNEL_LOCAL_MISS_LOG_COOLDOWN_SECONDS
)
logger.warning(
"tunnel node {} has no local connection on pid={} "
"(db_status={}, db_tunnel_connected={}), request may fail on this worker",
node_id,
os.getpid(),
str(getattr(node, "status", "unknown")),
bool(getattr(node, "tunnel_connected", False)),
)
_proxy_node_cache[node_id] = (
None,
now + _PROXY_NODE_CACHE_TUNNEL_LOCAL_MISS_TTL_SECONDS,
)
logger.warning(
"tunnel node {} has no local connection on pid={} "
"(db_status={}, db_tunnel_connected={}), request may fail on this worker",
node_id,
os.getpid(),
str(getattr(node, "status", "unknown")),
bool(getattr(node, "tunnel_connected", False)),
)
_proxy_node_cache[node_id] = (
None,
now + _PROXY_NODE_CACHE_TUNNEL_LOCAL_MISS_TTL_SECONDS,
)
return None
return None
value: dict[str, Any] = {
"name": node.name,
"ip": node.ip,
@@ -408,13 +462,13 @@ def build_proxy_client_kwargs(
kwargs: dict[str, Any] = {"timeout": timeout, "verify": verify, **extra}
# tunnel 模式优先:当代理节点为 tunnel 模式时,使用 TunnelTransport
# tunnel 模式优先:当代理节点为 tunnel 模式时,使用 tunnel transport 工厂
delegate_cfg = resolve_delegate_config(proxy_config)
if delegate_cfg and delegate_cfg.get("tunnel"):
from src.services.proxy_node.tunnel_transport import TunnelTransport
from src.services.proxy_node.tunnel_transport import create_tunnel_transport
timeout_secs = timeout if isinstance(timeout, (int, float)) else 60.0
kwargs["transport"] = TunnelTransport(delegate_cfg["node_id"], timeout=timeout_secs)
kwargs["transport"] = create_tunnel_transport(delegate_cfg["node_id"], timeout=timeout_secs)
return kwargs
proxy_param = resolve_proxy_param(proxy_config)
@@ -455,7 +509,12 @@ def build_proxy_url(proxy_config: dict[str, Any]) -> str | None:
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)
message = (
_build_tunnel_local_miss_message(node_id)
if _is_tunnel_local_miss(node_id)
else f"代理节点 {node_id} 不可用"
)
raise ProxyNodeUnavailableError(message, node_id=node_id)
# 手动节点:直接使用存储的代理 URL含认证信息
if node_info.get("is_manual"):

View File

@@ -177,10 +177,10 @@ async def _test_tunnel_connectivity(node_id: str) -> dict[str, Any]:
"""通过 WebSocket tunnel 测试连通性,返回标准化结果 dict"""
import time as _time
from .tunnel_transport import TunnelTransport
from .tunnel_transport import create_tunnel_transport
test_url = "https://1.1.1.1/cdn-cgi/trace"
transport = TunnelTransport(node_id, timeout=15.0)
transport = create_tunnel_transport(node_id, timeout=15.0)
start = _time.monotonic()
try:
@@ -556,17 +556,38 @@ class ProxyNodeService:
# tunnel 节点:通过 WebSocket tunnel 测试
if not node.is_manual:
# 以 TunnelManager 内存中的实际连接状态为准(与 health_scheduler 一致),
# 而非仅依赖 DB 的 tunnel_connected 字段,避免竞态导致误判。
from src.services.proxy_node.tunnel_manager import get_tunnel_manager
from src.services.proxy_node.hub_config import get_hub_config
manager = get_tunnel_manager()
if not manager.has_tunnel(node.id):
hub_enabled = get_hub_config().enabled
connected = False
if hub_enabled:
connected = bool(node.tunnel_connected) and node.status == ProxyNodeStatus.ONLINE
else:
# 非 Hub 模式:以当前 worker 本地 TunnelManager 状态为准,
# 避免多 worker 场景的跨进程状态误判。
from src.services.proxy_node.tunnel_manager import get_tunnel_manager
manager = get_tunnel_manager()
connected = manager.has_tunnel(node.id)
if not connected:
hint = ""
try:
from src.config import config
if config.worker_processes > 1 and not hub_enabled:
hint = (
"(当前 worker 无 tunnel 连接;检测到多 worker 部署,"
"建议设置 GUNICORN_WORKERS/WEB_CONCURRENCY=1"
)
except Exception:
# 配置读取失败时保持原始错误,避免影响主流程
hint = ""
return {
"success": False,
"latency_ms": None,
"exit_ip": None,
"error": "tunnel 未连接",
"error": f"tunnel 未连接{hint}",
}
result = await _test_tunnel_connectivity(node.id)

View File

@@ -29,6 +29,7 @@ class MsgType(IntEnum):
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
NODE_STATUS = 0x15 # Hub -> Worker: \u8282\u70b9\u8fde\u63a5\u72b6\u6001\u5e7f\u64ad
class FrameFlags:

View File

@@ -136,3 +136,15 @@ def is_tunnel_node(node_info: dict[str, Any] | None) -> bool:
if not node_info:
return False
return bool(node_info.get("tunnel_mode")) and bool(node_info.get("tunnel_connected"))
def create_tunnel_transport(node_id: str, timeout: float = 60.0) -> httpx.AsyncBaseTransport:
"""根据配置创建 tunnel transportHub 模式或直连 tunnel 模式)。"""
from .hub_config import get_hub_config
hub_cfg = get_hub_config()
if hub_cfg.enabled:
from .hub_transport import HubTunnelTransport
return HubTunnelTransport(node_id, timeout=timeout)
return TunnelTransport(node_id, timeout=timeout)