Files
Nexus/server/api/websocket.py
T
Your Name 8530f0e0d5 安全补强: 6项P0/P1漏洞修复
P0-1: PHP配置注入防护 — install.py添加_escape_php_string()转义单引号和反斜杠
P0-2: WebSocket JWT校验 — _verify_ws_token()要求exp+sub字段
P1-1: 删除SHA256密码fallback — auth_service.py和auth.py直接import bcrypt
P1-3: LIKE通配符转义 — search.py添加_escape_like()并对所有ilike()加escape参数
P1-2: 安全响应头中间件 — main.py添加SecurityHeadersMiddleware注入4个安全头
P0-3: Refresh Token重用检测 — Admin模型添加token_version字段,token格式改为token:admin_id:version

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-22 22:16:50 +08:00

363 lines
12 KiB
Python

"""Nexus — WebSocket Alert Push (Two-Layer Architecture)
ADR-010: Memory ConnectionManager + Redis Pub/Sub two-layer architecture.
- Layer 1: In-memory ConnectionManager tracks all local WebSocket connections
- Layer 2: Redis Pub/Sub relays messages across multiple uvicorn workers
- Alert/recovery/system events published to Redis channel `nexus:alerts`
- Each worker subscribes to `nexus:alerts` and pushes to local clients
ADR-011: WebSocket JWT authentication via query parameter `?token=xxx`
R4: Ping/pong heartbeat every 30s + zombie connection cleanup (60s idle → disconnect)
"""
import asyncio
import json
import logging
import time
from typing import Dict, Set, Optional
from fastapi import APIRouter, WebSocket, WebSocketDisconnect, Query
from server.config import settings
logger = logging.getLogger("nexus.websocket")
router = APIRouter()
# Redis Pub/Sub channel name
REDIS_CHANNEL = "nexus:alerts"
class ConnectionManager:
"""Layer 1: In-memory WebSocket connection manager.
Tracks all connected clients for this worker process.
Handles ping/pong heartbeat and zombie connection cleanup.
"""
def __init__(self):
# client_id → {websocket, last_pong_time}
self._connections: Dict[str, dict] = {}
self._heartbeat_task: Optional[asyncio.Task] = None
async def connect(self, client_id: str, websocket: WebSocket):
"""Accept and register a new WebSocket connection"""
await websocket.accept()
self._connections[client_id] = {
"websocket": websocket,
"last_pong": time.monotonic(),
}
logger.info(f"WebSocket connected: {client_id}, total: {len(self._connections)}")
# Start heartbeat task if first connection
if len(self._connections) == 1 and self._heartbeat_task is None:
self._heartbeat_task = asyncio.create_task(self._heartbeat_loop(), name="ws_heartbeat")
def disconnect(self, client_id: str):
"""Remove a WebSocket connection"""
if client_id in self._connections:
del self._connections[client_id]
logger.info(f"WebSocket disconnected: {client_id}, total: {len(self._connections)}")
# Stop heartbeat task if no connections
if not self._connections and self._heartbeat_task:
self._heartbeat_task.cancel()
self._heartbeat_task = None
async def send_to_client(self, client_id: str, message: dict) -> bool:
"""Send JSON message to a specific client. Returns False if send failed."""
conn = self._connections.get(client_id)
if not conn:
return False
try:
await conn["websocket"].send_json(message)
return True
except Exception:
self.disconnect(client_id)
return False
async def broadcast_local(self, message: dict):
"""Broadcast message to all locally connected clients (Layer 1 only)"""
stale = []
for client_id, conn in self._connections.items():
try:
await conn["websocket"].send_json(message)
except Exception:
stale.append(client_id)
for client_id in stale:
self.disconnect(client_id)
def update_pong(self, client_id: str):
"""Update last pong time for a client (received pong response)"""
conn = self._connections.get(client_id)
if conn:
conn["last_pong"] = time.monotonic()
@property
def client_count(self) -> int:
return len(self._connections)
async def _heartbeat_loop(self):
"""R4: Send ping every 30s, cleanup connections idle > 60s"""
while True:
await asyncio.sleep(30)
now = time.monotonic()
stale = []
for client_id, conn in self._connections.items():
# Check for zombie connections (no pong for 60s)
if now - conn["last_pong"] > 60:
stale.append(client_id)
continue
# Send ping
try:
await conn["websocket"].send_json({"type": "ping"})
except Exception:
stale.append(client_id)
# Cleanup stale connections
for client_id in stale:
self.disconnect(client_id)
logger.info(f"WebSocket zombie cleanup: {client_id}")
if stale:
logger.info(f"Heartbeat cleanup: removed {len(stale)} stale connections")
# Global connection manager for this worker
manager = ConnectionManager()
# ── Redis Pub/Sub Layer 2 ──
_pubsub_task: Optional[asyncio.Task] = None
_redis_subscriber = None
async def start_redis_subscriber():
"""Start Redis Pub/Sub subscriber (Layer 2) — called during app startup"""
global _pubsub_task, _redis_subscriber
try:
from server.infrastructure.redis.client import get_redis
redis = get_redis()
_redis_subscriber = redis.pubsub()
await _redis_subscriber.subscribe(REDIS_CHANNEL)
_pubsub_task = asyncio.create_task(_redis_listen_loop(), name="ws_redis_sub")
logger.info(f"Redis Pub/Sub subscriber started on channel: {REDIS_CHANNEL}")
except Exception as e:
logger.error(f"Redis Pub/Sub subscriber failed to start: {e}")
# Non-fatal — WebSocket still works locally
async def stop_redis_subscriber():
"""Stop Redis Pub/Sub subscriber — called during app shutdown"""
global _pubsub_task, _redis_subscriber
if _pubsub_task:
_pubsub_task.cancel()
try:
await _pubsub_task
except asyncio.CancelledError:
pass
_pubsub_task = None
if _redis_subscriber:
try:
await _redis_subscriber.unsubscribe(REDIS_CHANNEL)
await _redis_subscriber.aclose()
except Exception:
pass
_redis_subscriber = None
logger.info("Redis Pub/Sub subscriber stopped")
async def _redis_listen_loop():
"""Listen for messages on Redis Pub/Sub channel and broadcast to local clients"""
while True:
try:
message = await _redis_subscriber.get_message(
ignore_subscribe_messages=True, timeout=1.0
)
if message and message.get("type") == "message":
data = message.get("data")
if isinstance(data, str):
msg = json.loads(data)
await manager.broadcast_local(msg)
except asyncio.CancelledError:
break
except Exception as e:
logger.error(f"Redis Pub/Sub listen error: {e}")
await asyncio.sleep(1) # Back off on error
async def _publish_to_redis(message: dict):
"""Publish message to Redis channel (Layer 2) — other workers will receive it"""
try:
from server.infrastructure.redis.client import get_redis
redis = get_redis()
await redis.publish(REDIS_CHANNEL, json.dumps(message))
except Exception as e:
logger.error(f"Redis Pub/Sub publish failed: {e}")
# ── WebSocket Endpoint ──
@router.websocket("/ws/alerts")
async def alert_ws(
websocket: WebSocket,
token: Optional[str] = Query(None, description="JWT access token"),
):
"""WebSocket endpoint for real-time alert push to frontend
ADR-011: JWT authentication via query parameter `?token=xxx`
R4: Ping/pong heartbeat + zombie connection cleanup
"""
# ── JWT Authentication (ADR-011) ──
if not token:
await websocket.close(code=4001, reason="Missing JWT token")
return
admin = await _verify_ws_token(token)
if not admin:
await websocket.close(code=4001, reason="Invalid or expired JWT token")
return
# ── Accept and register ──
client_id = f"{admin.id}:{id(websocket)}"
await manager.connect(client_id, websocket)
try:
while True:
data = await websocket.receive_text()
if data == "pong":
manager.update_pong(client_id)
elif data == "ping":
await websocket.send_json({"type": "pong"})
except WebSocketDisconnect:
manager.disconnect(client_id)
except Exception as e:
logger.warning(f"WebSocket error for {client_id}: {e}")
manager.disconnect(client_id)
async def _verify_ws_token(token: str):
"""Verify JWT token for WebSocket connection (same logic as get_current_admin)"""
try:
import jwt as pyjwt
from server.infrastructure.database.session import AsyncSessionLocal
from server.infrastructure.database.admin_repo import AdminRepositoryImpl
payload = pyjwt.decode(token, settings.SECRET_KEY, algorithms=["HS256"],
options={"require": ["exp", "sub"]})
admin_id = payload.get("sub")
if not admin_id:
return None
try:
admin_id = int(admin_id)
except (ValueError, TypeError):
return None
async with AsyncSessionLocal() as session:
admin_repo = AdminRepositoryImpl(session)
admin = await admin_repo.get_by_id(admin_id)
if admin and admin.is_active:
return admin
return None
except Exception:
return None
# ── Alert Aggregation / Dedup ──
# Track last alert time per (server_id, alert_type) to avoid Telegram alert storms.
# WebSocket broadcasts are always sent (real-time), but Telegram pushes are deduplicated.
_ALERT_COOLDOWN_SECONDS = 300 # 5 minutes — same server+metric won't re-push Telegram
_last_alert_time: Dict[str, float] = {} # key="alert:{server_id}:{type}" → monotonic timestamp
def _should_push_telegram(alert_key: str) -> bool:
"""Check if enough time has passed since last Telegram push for this alert key.
Returns True if we should send the Telegram notification, False if suppressed (cooldown).
"""
now = time.monotonic()
last = _last_alert_time.get(alert_key, 0)
if now - last < _ALERT_COOLDOWN_SECONDS:
return False # Suppressed — too soon
_last_alert_time[alert_key] = now
return True
# ── Broadcast Functions (called from other modules) ──
async def broadcast_alert(server_id: int, alert_type: str, alert_value: float, server_name: str = ""):
"""Broadcast alert to all WebSocket clients + Telegram
Called when Agent heartbeat detects CPU/memory/disk > threshold.
Publishes to Redis Pub/Sub (multi-worker) + Telegram.
Alert aggregation: WebSocket always broadcasts (real-time),
but Telegram push is deduplicated (5min cooldown per server+metric).
"""
msg = {
"type": "alert",
"server_id": server_id,
"server_name": server_name,
"alert_type": alert_type,
"alert_value": alert_value,
}
# Broadcast to local clients + publish to Redis for other workers
await manager.broadcast_local(msg)
await _publish_to_redis(msg)
# Telegram push — deduplicated per (server_id, alert_type) with cooldown
alert_key = f"alert:{server_id}:{alert_type}"
if _should_push_telegram(alert_key):
from server.infrastructure.telegram import send_telegram_alert
await send_telegram_alert(server_name or str(server_id), alert_type, alert_value)
else:
logger.debug(f"Alert suppressed (cooldown): {alert_key}")
async def broadcast_recovery(server_id: int, metric: str, value: float, server_name: str = ""):
"""Broadcast recovery notification when alert metric returns to normal
Recovery alerts are always pushed (clears the alert state).
Also clears the alert cooldown so next alert will be sent immediately.
"""
msg = {
"type": "recovery",
"server_id": server_id,
"server_name": server_name,
"metric": metric,
"value": value,
}
await manager.broadcast_local(msg)
await _publish_to_redis(msg)
# Recovery always sends Telegram + clears cooldown for next alert cycle
alert_key = f"alert:{server_id}:{metric}"
_last_alert_time.pop(alert_key, None) # Clear cooldown
from server.infrastructure.telegram import send_telegram_recovery
await send_telegram_recovery(server_name or str(server_id), metric, value)
async def broadcast_system_event(event_type: str, message: str):
"""Broadcast system-level events (Python restart, Redis reconnect, etc.)"""
msg = {"type": "system", "event_type": event_type, "message": message}
await manager.broadcast_local(msg)
await _publish_to_redis(msg)
# Backward compatibility alias for health.py
connected_clients = [] # Deprecated — use manager.client_count instead