新增设备身份管理、认证限流、并发通道、Webhook路由、RBAC权限控制、SSE/轮询降级等全套网关通道功能,包含: 1. 设备身份生成与签名验证 2. 设备令牌认证与速率限制 3. 内存+数据库双重设备注册表 4. 并发通道限流管理 5. Webhook安全处理与路由 6. RBAC权限校验系统 7. OpenAI API兼容适配层 8. Tailscale认证支持 9. HTTP轮询降级机制
305 lines
11 KiB
Python
305 lines
11 KiB
Python
import hashlib
|
|
import hmac
|
|
import logging
|
|
import time
|
|
from dataclasses import dataclass
|
|
from enum import StrEnum
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
PRE_AUTH_BODY_SIZE_LIMIT = 64 * 1024
|
|
POST_AUTH_BODY_SIZE_LIMIT = 1 * 1024 * 1024
|
|
PRE_AUTH_READ_TIMEOUT = 5.0
|
|
POST_AUTH_READ_TIMEOUT = 30.0
|
|
MAX_CONCURRENT_PER_KEY = 8
|
|
MAX_TRACKING_KEYS = 4096
|
|
|
|
_VALID_WEBHOOK_METHODS = frozenset({"POST", "PUT", "PATCH"})
|
|
|
|
|
|
class WebhookErrorCode(StrEnum):
|
|
METHOD_NOT_ALLOWED = "METHOD_NOT_ALLOWED"
|
|
CONTENT_TYPE_UNSUPPORTED = "CONTENT_TYPE_UNSUPPORTED"
|
|
BODY_TOO_LARGE = "BODY_TOO_LARGE"
|
|
TOO_MANY_REQUESTS = "TOO_MANY_REQUESTS"
|
|
SIGNATURE_MISSING = "SIGNATURE_MISSING"
|
|
SIGNATURE_INVALID = "SIGNATURE_INVALID"
|
|
PAYLOAD_INVALID = "PAYLOAD_INVALID"
|
|
|
|
|
|
@dataclass
|
|
class WebhookGuardConfig:
|
|
channel_type: str
|
|
allowed_methods: tuple[str, ...] = ("POST",)
|
|
allowed_content_types: tuple[str, ...] = ("application/json",)
|
|
pre_auth: bool = True
|
|
verify_signature: bool = True
|
|
body_size_limit: int = PRE_AUTH_BODY_SIZE_LIMIT
|
|
|
|
|
|
import threading
|
|
|
|
class WebhookAnomalyTracker:
|
|
def __init__(self, sample_window: float = 60.0, alert_threshold: int = 50):
|
|
self._sample_window = sample_window
|
|
self._alert_threshold = alert_threshold
|
|
self._counters: dict[str, dict[str, int]] = {}
|
|
self._last_reset: float = time.monotonic()
|
|
self._lock = threading.Lock()
|
|
|
|
def record(self, channel_type: str, error_code: str) -> None:
|
|
now = time.monotonic()
|
|
with self._lock:
|
|
if now - self._last_reset > self._sample_window:
|
|
self._counters.clear()
|
|
self._last_reset = now
|
|
|
|
channel_counters = self._counters.setdefault(channel_type, {})
|
|
channel_counters[error_code] = channel_counters.get(error_code, 0) + 1
|
|
|
|
total = sum(channel_counters.values())
|
|
if total >= self._alert_threshold and total % self._alert_threshold == 0:
|
|
self._emit_alert(channel_type, channel_counters)
|
|
|
|
def _emit_alert(self, channel_type: str, counters: dict[str, int]) -> None:
|
|
details = ", ".join(f"{k}={v}" for k, v in counters.items())
|
|
logger.warning(
|
|
"Webhook anomaly alert [%s]: %d anomalies in window — %s",
|
|
channel_type,
|
|
sum(counters.values()),
|
|
details,
|
|
)
|
|
|
|
def get_stats(self, channel_type: str | None = None) -> dict[str, dict[str, int]]:
|
|
with self._lock:
|
|
if channel_type:
|
|
return {channel_type: self._counters.get(channel_type, {})}
|
|
return dict(self._counters)
|
|
|
|
|
|
class WebhookSigner:
|
|
def __init__(self):
|
|
self._signing_keys: dict[str, str] = {}
|
|
|
|
def set_channel_key(self, channel_type: str, signing_key: str) -> None:
|
|
self._signing_keys[channel_type] = signing_key
|
|
|
|
def remove_channel_key(self, channel_type: str) -> None:
|
|
self._signing_keys.pop(channel_type, None)
|
|
|
|
def compute_signature(self, channel_type: str, body: bytes) -> str | None:
|
|
key = self._signing_keys.get(channel_type)
|
|
if not key:
|
|
return None
|
|
return hmac.new(key.encode(), body, hashlib.sha256).hexdigest()
|
|
|
|
def verify(
|
|
self,
|
|
channel_type: str,
|
|
body: bytes,
|
|
signature: str,
|
|
) -> bool:
|
|
expected = self.compute_signature(channel_type, body)
|
|
if expected is None:
|
|
return False
|
|
return hmac.compare_digest(expected, signature)
|
|
|
|
@staticmethod
|
|
def compute_sha256_signature(secret: str, body: bytes) -> str:
|
|
return hmac.new(secret.encode(), body, hashlib.sha256).hexdigest()
|
|
|
|
|
|
class WebhookConcurrencyGuard:
|
|
def __init__(self, max_per_key: int = MAX_CONCURRENT_PER_KEY, max_keys: int = MAX_TRACKING_KEYS):
|
|
self._max_per_key = max_per_key
|
|
self._max_keys = max_keys
|
|
self._inflight: dict[str, int] = {}
|
|
self._lock = threading.Lock()
|
|
|
|
def try_acquire(self, channel_type: str) -> bool:
|
|
with self._lock:
|
|
current = self._inflight.get(channel_type, 0)
|
|
if current >= self._max_per_key:
|
|
return False
|
|
if len(self._inflight) >= self._max_keys and channel_type not in self._inflight:
|
|
return False
|
|
self._inflight[channel_type] = current + 1
|
|
return True
|
|
|
|
def release(self, channel_type: str) -> None:
|
|
with self._lock:
|
|
current = self._inflight.get(channel_type, 0)
|
|
if current <= 1:
|
|
self._inflight.pop(channel_type, None)
|
|
else:
|
|
self._inflight[channel_type] = current - 1
|
|
|
|
@property
|
|
def inflight_count(self) -> dict[str, int]:
|
|
with self._lock:
|
|
return dict(self._inflight)
|
|
|
|
|
|
@dataclass
|
|
class WebhookGuardResult:
|
|
allowed: bool
|
|
error_code: WebhookErrorCode | None = None
|
|
error_message: str | None = None
|
|
http_status: int = 200
|
|
|
|
|
|
class WebhookGuard:
|
|
def __init__(
|
|
self,
|
|
signer: WebhookSigner | None = None,
|
|
concurrency_guard: WebhookConcurrencyGuard | None = None,
|
|
anomaly_tracker: WebhookAnomalyTracker | None = None,
|
|
):
|
|
self._signer = signer or WebhookSigner()
|
|
self._concurrency = concurrency_guard or WebhookConcurrencyGuard()
|
|
self._anomaly = anomaly_tracker or WebhookAnomalyTracker()
|
|
self._channel_configs: dict[str, WebhookGuardConfig] = {}
|
|
|
|
def register_channel(self, channel_type: str, config: WebhookGuardConfig | None = None) -> None:
|
|
self._channel_configs[channel_type] = config or WebhookGuardConfig(channel_type=channel_type)
|
|
|
|
def unregister_channel(self, channel_type: str) -> None:
|
|
self._channel_configs.pop(channel_type, None)
|
|
|
|
def check_method(self, channel_type: str, method: str) -> WebhookGuardResult:
|
|
cfg = self._channel_configs.get(channel_type)
|
|
allowed = list(cfg.allowed_methods) if cfg else ["POST"]
|
|
if method not in _VALID_WEBHOOK_METHODS or method not in allowed:
|
|
self._anomaly.record(channel_type, "405")
|
|
return WebhookGuardResult(
|
|
allowed=False,
|
|
error_code=WebhookErrorCode.METHOD_NOT_ALLOWED,
|
|
error_message=f"Method {method} not allowed",
|
|
http_status=405,
|
|
)
|
|
return WebhookGuardResult(allowed=True)
|
|
|
|
def check_content_type(self, channel_type: str, content_type: str | None) -> WebhookGuardResult:
|
|
cfg = self._channel_configs.get(channel_type)
|
|
allowed = list(cfg.allowed_content_types) if cfg else ["application/json"]
|
|
if not content_type:
|
|
self._anomaly.record(channel_type, "415")
|
|
return WebhookGuardResult(
|
|
allowed=False,
|
|
error_code=WebhookErrorCode.CONTENT_TYPE_UNSUPPORTED,
|
|
error_message="Content-Type header missing",
|
|
http_status=415,
|
|
)
|
|
ctype_main = content_type.split(";")[0].strip().lower()
|
|
if ctype_main not in allowed:
|
|
self._anomaly.record(channel_type, "415")
|
|
return WebhookGuardResult(
|
|
allowed=False,
|
|
error_code=WebhookErrorCode.CONTENT_TYPE_UNSUPPORTED,
|
|
error_message=f"Content-Type {content_type} not supported",
|
|
http_status=415,
|
|
)
|
|
return WebhookGuardResult(allowed=True)
|
|
|
|
def check_body_size(self, channel_type: str, body: bytes) -> WebhookGuardResult:
|
|
cfg = self._channel_configs.get(channel_type)
|
|
limit = cfg.body_size_limit if cfg else PRE_AUTH_BODY_SIZE_LIMIT
|
|
if len(body) > limit:
|
|
self._anomaly.record(channel_type, "413")
|
|
return WebhookGuardResult(
|
|
allowed=False,
|
|
error_code=WebhookErrorCode.BODY_TOO_LARGE,
|
|
error_message=f"Body size {len(body)} exceeds limit {limit}",
|
|
http_status=413,
|
|
)
|
|
return WebhookGuardResult(allowed=True)
|
|
|
|
def check_concurrency(self, channel_type: str) -> WebhookGuardResult:
|
|
if not self._concurrency.try_acquire(channel_type):
|
|
self._anomaly.record(channel_type, "429")
|
|
return WebhookGuardResult(
|
|
allowed=False,
|
|
error_code=WebhookErrorCode.TOO_MANY_REQUESTS,
|
|
error_message="Too many concurrent requests",
|
|
http_status=429,
|
|
)
|
|
return WebhookGuardResult(allowed=True)
|
|
|
|
def release_concurrency(self, channel_type: str) -> None:
|
|
self._concurrency.release(channel_type)
|
|
|
|
def check_signature(
|
|
self,
|
|
channel_type: str,
|
|
body: bytes,
|
|
signature: str | None,
|
|
) -> WebhookGuardResult:
|
|
cfg = self._channel_configs.get(channel_type)
|
|
if cfg is None or not cfg.verify_signature:
|
|
return WebhookGuardResult(allowed=True)
|
|
|
|
if not signature:
|
|
self._anomaly.record(channel_type, "401")
|
|
return WebhookGuardResult(
|
|
allowed=False,
|
|
error_code=WebhookErrorCode.SIGNATURE_MISSING,
|
|
error_message="Webhook signature missing",
|
|
http_status=401,
|
|
)
|
|
|
|
if not self._signer.verify(channel_type, body, signature):
|
|
self._anomaly.record(channel_type, "401")
|
|
return WebhookGuardResult(
|
|
allowed=False,
|
|
error_code=WebhookErrorCode.SIGNATURE_INVALID,
|
|
error_message="Webhook signature invalid",
|
|
http_status=401,
|
|
)
|
|
|
|
return WebhookGuardResult(allowed=True)
|
|
|
|
def run_pipeline(
|
|
self,
|
|
channel_type: str,
|
|
method: str,
|
|
content_type: str | None,
|
|
body: bytes,
|
|
signature: str | None = None,
|
|
) -> WebhookGuardResult:
|
|
result = self.check_method(channel_type, method)
|
|
if not result.allowed:
|
|
return result
|
|
|
|
result = self.check_content_type(channel_type, content_type)
|
|
if not result.allowed:
|
|
return result
|
|
|
|
result = self.check_body_size(channel_type, body)
|
|
if not result.allowed:
|
|
return result
|
|
|
|
result = self.check_signature(channel_type, body, signature)
|
|
if not result.allowed:
|
|
return result
|
|
|
|
result = self.check_concurrency(channel_type)
|
|
if not result.allowed:
|
|
return result
|
|
|
|
return WebhookGuardResult(allowed=True)
|
|
|
|
@property
|
|
def signer(self) -> WebhookSigner:
|
|
return self._signer
|
|
|
|
@property
|
|
def anomaly_tracker(self) -> WebhookAnomalyTracker:
|
|
return self._anomaly
|
|
|
|
@property
|
|
def concurrency_guard(self) -> WebhookConcurrencyGuard:
|
|
return self._concurrency
|
|
|
|
|
|
webhook_guard = WebhookGuard()
|