ForcePilot/backend/package/yuxi/channel/gateway/routes.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

162 lines
4.8 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import asyncio
import logging
from collections.abc import Callable
from typing import Any
from yuxi.channel.gateway.protocol import DeliveryMode
from yuxi.channel.gateway.webhook_security import (
WebhookGuard,
WebhookGuardConfig,
WebhookGuardResult,
)
from yuxi.channel.gateway.webhook_security import (
webhook_guard as _default_webhook_guard,
)
logger = logging.getLogger(__name__)
WebhookHandler = Callable[[dict[str, Any]], asyncio.Future[dict[str, Any]]]
class WebhookRegistry:
"""渠道 Webhook 处理器注册表。
每个渠道插件注册自己的 webhook 处理函数,
HTTP 层统一通过 FastAPI router 将请求分发到此处。
集成了多层 Webhook 安全 Guard
- Method 检查(仅允许注册的 HTTP 方法)
- Content-Type 验证
- Body 大小限制
- HMAC-SHA256 签名校验
- 并发限制
- 异常追踪告警
"""
def __init__(self, webhook_guard: WebhookGuard | None = None):
self._handlers: dict[str, WebhookHandler] = {}
self._delivery_modes: dict[str, DeliveryMode] = {}
self._guard = webhook_guard or _default_webhook_guard
def register(
self,
channel_type: str,
handler: WebhookHandler,
delivery_mode: DeliveryMode = DeliveryMode.DIRECT,
guard_config: WebhookGuardConfig | None = None,
) -> None:
if channel_type in self._handlers:
logger.warning("Overwriting webhook handler for channel: %s", channel_type)
self._handlers[channel_type] = handler
self._delivery_modes[channel_type] = delivery_mode
self._guard.register_channel(channel_type, guard_config)
def get_handler(self, channel_type: str) -> WebhookHandler | None:
return self._handlers.get(channel_type)
def get_delivery_mode(self, channel_type: str) -> DeliveryMode:
return self._delivery_modes.get(channel_type, DeliveryMode.DIRECT)
def list_channels(self) -> list[dict[str, str]]:
return [
{
"channel_type": ct,
"delivery_mode": self._delivery_modes.get(ct, DeliveryMode.DIRECT).value,
}
for ct in self._handlers
]
def remove(self, channel_type: str) -> None:
self._handlers.pop(channel_type, None)
self._delivery_modes.pop(channel_type, None)
self._guard.unregister_channel(channel_type)
def clear(self) -> None:
for ct in list(self._handlers):
self._guard.unregister_channel(ct)
self._handlers.clear()
self._delivery_modes.clear()
async def dispatch_guarded(
self,
channel_type: str,
method: str,
content_type: str | None,
body: bytes,
signature: str | None = None,
extra_headers: dict[str, str] | None = None,
) -> tuple[WebhookGuardResult | None, dict[str, Any] | None]:
guard_result = self._guard.run_pipeline(
channel_type,
method,
content_type,
body,
signature,
)
if not guard_result.allowed:
return guard_result, None
handler = self._handlers.get(channel_type)
if handler is None:
self._guard.release_concurrency(channel_type)
return WebhookGuardResult(
allowed=False,
error_code=None,
error_message=f"No handler registered for channel: {channel_type}",
http_status=404,
), None
try:
import json
payload = json.loads(body)
except Exception:
self._guard.release_concurrency(channel_type)
return WebhookGuardResult(
allowed=False,
error_code=None,
error_message="Invalid JSON payload",
http_status=400,
), None
if extra_headers:
payload["_headers"] = extra_headers
payload["_raw_body"] = body
try:
result = await handler(payload)
except Exception:
logger.exception("Webhook handler failed for channel: %s", channel_type)
result = None
finally:
self._guard.release_concurrency(channel_type)
return None, result
@property
def guard(self) -> WebhookGuard:
return self._guard
webhook_registry = WebhookRegistry()
import re
_WEBHOOK_PATH_RE = re.compile(r"^[a-zA-Z0-9_-]+$")
def build_webhook_path(channel_type: str) -> str:
if not channel_type or not _WEBHOOK_PATH_RE.match(channel_type):
raise ValueError(f"Invalid channel_type: {channel_type!r}")
return f"/webhook/{channel_type}"
def build_health_response(status: str, channels: dict, summary: dict, ts: str | None = None) -> dict:
return {
"status": status,
"timestamp": ts,
"channels": channels,
"summary": summary,
}