ForcePilot/backend/package/yuxi/channel/gateway/probe.py
Kris ecd3c90e80 feat(channel/gateway): 新增完整网关通道模块
新增设备身份管理、认证限流、并发通道、Webhook路由、RBAC权限控制、SSE/轮询降级等全套网关通道功能,包含:
1. 设备身份生成与签名验证
2. 设备令牌认证与速率限制
3. 内存+数据库双重设备注册表
4. 并发通道限流管理
5. Webhook安全处理与路由
6. RBAC权限校验系统
7. OpenAI API兼容适配层
8. Tailscale认证支持
9. HTTP轮询降级机制
2026-05-21 10:26:33 +08:00

290 lines
9.7 KiB
Python

import asyncio
import logging
import time
from dataclasses import dataclass, field
from enum import StrEnum
import aiohttp
from yuxi.channel.gateway.protocol import (
RpcRequest,
RpcResponse,
marshal_frame,
unmarshal_frame,
)
logger = logging.getLogger(__name__)
DEFAULT_PROBE_TIMEOUT_MS = 10_000
MIN_PROBE_TIMEOUT_MS = 250
MAX_PROBE_TIMEOUT_MS = 30_000
class ProbeCapability(StrEnum):
UNKNOWN = "unknown"
PAIRING_PENDING = "pairing_pending"
CONNECTED_NO_OPERATOR_SCOPE = "connected_no_operator_scope"
READ_ONLY = "read_only"
WRITE_CAPABLE = "write_capable"
ADMIN_CAPABLE = "admin_capable"
@dataclass
class ProbeAuthSummary:
role: str | None = None
scopes: list[str] = field(default_factory=list)
capability: ProbeCapability = ProbeCapability.UNKNOWN
@dataclass
class ProbeServerSummary:
version: str | None = None
conn_id: str | None = None
@dataclass
class ProbeClose:
code: int
reason: str
hint: str | None = None
@dataclass
class GatewayProbeResult:
ok: bool
url: str
connect_latency_ms: float | None = None
error: str | None = None
close: ProbeClose | None = None
auth: ProbeAuthSummary = field(default_factory=ProbeAuthSummary)
server: ProbeServerSummary = field(default_factory=ProbeServerSummary)
health: dict | None = None
status: dict | None = None
presence: list | None = None
config_snapshot: dict | None = None
def _clamp_timeout_ms(timeout_ms: float | int) -> float:
return max(MIN_PROBE_TIMEOUT_MS, min(float(timeout_ms), MAX_PROBE_TIMEOUT_MS))
def _resolve_capability(
scopes: list[str],
connect_latency_ms: float | None,
auth_metadata_present: bool,
) -> ProbeCapability:
if "operator.admin" in scopes:
return ProbeCapability.ADMIN_CAPABLE
if "operator.write" in scopes:
return ProbeCapability.WRITE_CAPABLE
if "operator.read" in scopes or "admin" in scopes:
return ProbeCapability.READ_ONLY
if connect_latency_ms is not None and auth_metadata_present:
return ProbeCapability.CONNECTED_NO_OPERATOR_SCOPE
return ProbeCapability.UNKNOWN
async def probe_gateway(
url: str,
token: str | None = None,
password: str | None = None,
timeout_ms: float = DEFAULT_PROBE_TIMEOUT_MS,
detail_level: str = "full",
) -> GatewayProbeResult:
connect_latency_ms: float | None = None
connect_error: str | None = None
close_info: ProbeClose | None = None
auth_summary = ProbeAuthSummary()
server_summary = ProbeServerSummary()
auth_metadata_present = False
effective_timeout = _clamp_timeout_ms(timeout_ms)
timeout_sec = effective_timeout / 1000.0
headers: dict[str, str] = {}
if token:
headers["Authorization"] = f"Bearer {token}"
elif password:
headers["Authorization"] = f"Bearer {password}"
conn_start = time.monotonic()
try:
async with aiohttp.ClientSession(
timeout=aiohttp.ClientTimeout(total=timeout_sec),
) as session:
async with session.ws_connect(
url,
headers=headers,
heartbeat=15.0,
) as ws:
connect_latency_ms = (time.monotonic() - conn_start) * 1000
auth_metadata_present = True
if detail_level == "none":
ws_close = ws.close_code
if ws_close is not None:
close_info = ProbeClose(
code=ws_close,
reason="connection closed by server",
)
return GatewayProbeResult(
ok=ws_close is None,
url=url,
connect_latency_ms=connect_latency_ms,
error=None,
close=close_info,
auth=auth_summary,
server=server_summary,
)
rpc_request_id = "probe"
rpc_request = RpcRequest(
id=rpc_request_id,
method="system.health",
params={},
)
await ws.send_str(marshal_frame(rpc_request))
try:
raw = await asyncio.wait_for(
ws.receive_str(),
timeout=effective_timeout / 1000.0,
)
except asyncio.TimeoutError:
return GatewayProbeResult(
ok=False,
url=url,
connect_latency_ms=connect_latency_ms,
error="timeout waiting for health response",
auth=auth_summary,
server=server_summary,
)
try:
frame = unmarshal_frame(raw)
except Exception:
return GatewayProbeResult(
ok=False,
url=url,
connect_latency_ms=connect_latency_ms,
error="invalid response frame from gateway",
auth=auth_summary,
server=server_summary,
)
if not isinstance(frame, RpcResponse) or not frame.ok:
error_msg = frame.error_message if isinstance(frame, RpcResponse) else "unexpected frame type"
return GatewayProbeResult(
ok=False,
url=url,
connect_latency_ms=connect_latency_ms,
error=error_msg or "gateway returned error",
auth=auth_summary,
server=server_summary,
)
health_data = frame.result
auth_summary = ProbeAuthSummary(
role="viewer",
scopes=[],
capability=ProbeCapability.READ_ONLY,
)
if detail_level == "presence":
return GatewayProbeResult(
ok=True,
url=url,
connect_latency_ms=connect_latency_ms,
health=health_data,
auth=auth_summary,
server=server_summary,
presence=health_data.get("channels") if health_data else None,
)
status_data: dict | None = None
try:
status_request = RpcRequest(
id="probe-status",
method="channels.status",
params={},
)
await ws.send_str(marshal_frame(status_request))
raw_status = await asyncio.wait_for(
ws.receive_str(),
timeout=effective_timeout / 1000.0,
)
status_frame = unmarshal_frame(raw_status)
if isinstance(status_frame, RpcResponse) and status_frame.ok:
status_data = status_frame.result
except Exception:
logger.debug("Failed to get channel status during probe", exc_info=True)
return GatewayProbeResult(
ok=True,
url=url,
connect_latency_ms=connect_latency_ms,
health=health_data,
status=status_data,
auth=auth_summary,
server=server_summary,
)
except aiohttp.ClientConnectorError as e:
connect_error = f"连接失败: {e}"
except aiohttp.WSServerHandshakeError as e:
connect_error = f"WebSocket 握手失败: {e.status} {e.message}"
except aiohttp.ClientError as e:
connect_error = f"客户端错误: {e}"
except asyncio.TimeoutError:
connect_error = "连接超时"
except Exception as e:
connect_error = f"未知错误: {e}"
return GatewayProbeResult(
ok=False,
url=url,
connect_latency_ms=connect_latency_ms,
error=connect_error,
close=close_info,
auth=auth_summary,
server=server_summary,
)
def format_probe_result(result: GatewayProbeResult, verbose: bool = False) -> str:
lines: list[str] = []
if result.ok:
lines.append(f"✓ 网关探测成功: {result.url}")
if result.connect_latency_ms is not None:
lines.append(f" 连接延迟: {result.connect_latency_ms:.1f}ms")
lines.append(f" 能力级别: {result.auth.capability.value}")
else:
lines.append(f"✗ 网关探测失败: {result.url}")
if result.connect_latency_ms is not None:
lines.append(f" 连接延迟: {result.connect_latency_ms:.1f}ms")
if result.error:
lines.append(f" 错误: {result.error}")
if result.close:
lines.append(f" 关闭信息: code={result.close.code} reason={result.close.reason}")
if verbose and result.health:
lines.append("")
lines.append("健康报告:")
health = result.health
if isinstance(health, dict):
lines.append(f" 状态: {health.get('status', 'unknown')}")
summary = health.get("summary", {})
if summary:
lines.append(
f" 频道: 总计={summary.get('total', 0)}, "
f"运行={summary.get('running', 0)}, "
f"停止={summary.get('stopped', 0)}, "
f"异常={summary.get('unhealthy', 0)}"
)
return "\n".join(lines)