"""WebSocket-прокси для VNC-консоли. Поток данных: 1. Backend получает от Proxmox одноразовый тикет через `vncproxy` (REST API). 2. Открывает WebSocket к Proxmox с этим тикетом и портом. Аутентификация: - API-токен (заголовок `Authorization: PVEAPIToken=...`) — новые PVE ≥ 7.x - Cookie PVEAuthCookie — если заданы PVE_USERNAME + PVE_PASSWORD 3. Проксирует бинарный VNC-поток между браузером клиента и Proxmox. Используется aiohttp вместо websockets, потому что: - Proxmox 8.x+ делает 302 Redirect с Location со схемой https:// вместо wss://, а websockets на uvloop не умеет правильно следовать за такими редиректами (raise InvalidURI "scheme isn't ws or wss"). - aiohttp корректно обрабатывает HTTP→WS-редиректы с заменой scheme. """ import asyncio import logging from typing import Optional, Tuple from urllib.parse import quote import aiohttp import httpx from aiohttp import ClientSession, WSMsgType from fastapi import APIRouter, Query, WebSocket, WebSocketDisconnect from ..config import settings logger = logging.getLogger(__name__) router = APIRouter(prefix="/console", tags=["console"]) async def _get_pve_auth_cookie() -> Optional[Tuple[str, str]]: """Аутентифицируется в Proxmox по логину/паролю через REST API. Возвращает кортеж (cookie_header, csrf_token) или None, если PVE_USERNAME/PVE_PASSWORD не заданы. """ if not settings.pve_username or not settings.pve_password: return None url = f"{settings.pve_host}/api2/json/access/ticket" payload = {"username": settings.pve_username, "password": settings.pve_password} verify = settings.pve_verify_ssl try: async with httpx.AsyncClient(verify=verify, timeout=10.0) as client: resp = await client.post(url, data=payload) resp.raise_for_status() data = resp.json().get("data", {}) cookie = data.get("ticket") csrf = data.get("CSRFPreventionToken") if not cookie: logger.error("auth: Proxmox не вернул PVEAuthCookie") return None logger.info("auth: PVEAuthCookie получен успешно") return f"PVEAuthCookie={cookie}", csrf or "" except Exception as exc: logger.exception("auth: ошибка аутентификации в Proxmox") return None @router.websocket("/ws") async def console_ws( websocket: WebSocket, node: str = Query(...), vmid: int = Query(...), guest_type: str = Query(...), port: int = Query(...), ticket: str = Query(...), ): """Проксирует бинарный VNC-поток между браузером и Proxmox.""" await websocket.accept() guest_path = "qemu" if guest_type == "vm" else "lxc" pve_host_only = settings.pve_host.replace("http://", "").replace("https://", "") # Экранируем спецсимволы в тикете/порте (vncticket может содержать # / % :). safe_ticket = quote(ticket, safe="") safe_port = quote(str(port), safe="") upstream_url = ( f"wss://{pve_host_only}/api2/json/nodes/{node}/{guest_path}/{vmid}/vncwebsocket" f"?port={safe_port}&vncticket={safe_ticket}" ) # Определяем способ аутентификации: cookie приоритетнее (некоторые PVE # не принимают API-токен на WebSocket). headers: dict = {} auth = await _get_pve_auth_cookie() if auth: cookie_header, _ = auth headers["Cookie"] = cookie_header auth_kind = "cookie" else: headers["Authorization"] = ( f"PVEAPIToken={settings.pve_token_name}={settings.pve_token_value}" ) auth_kind = "api-token" logger.info( "console_ws: connecting node=%s vmid=%s guest=%s port=%s auth=%s", node, vmid, guest_type, port, auth_kind, ) upstream: Optional[aiohttp.ClientWebSocketResponse] = None session: Optional[ClientSession] = None try: # aiohttp сам следует за HTTP-редиректами и при этом корректно # меняет схему с https на wss (что websockets на uvloop не делает). session = ClientSession() upstream = await session.ws_connect( upstream_url, headers=headers, ssl=False if not settings.pve_verify_ssl else None, autoclose=False, autoping=True, max_msg_size=8 * 1024 * 1024, ) logger.info("console_ws: upstream connected") async def client_to_upstream(): try: while True: data = await websocket.receive_bytes() await upstream.send_bytes(data) except WebSocketDisconnect: logger.info("console_ws: client disconnected") except Exception as exc: logger.warning("console_ws: client_to_upstream error: %s", exc) async def upstream_to_client(): try: async for msg in upstream: if msg.type == WSMsgType.BINARY: await websocket.send_bytes(msg.data) elif msg.type == WSMsgType.TEXT: await websocket.send_bytes(msg.data.encode()) elif msg.type == WSMsgType.CLOSE: logger.info("console_ws: upstream close: %s", msg.data) break elif msg.type == WSMsgType.ERROR: logger.warning("console_ws: upstream error: %s", msg.data) break except Exception as exc: logger.warning("console_ws: upstream_to_client error: %s", exc) await asyncio.gather(client_to_upstream(), upstream_to_client()) except aiohttp.ClientResponseError as exc: logger.error( "console_ws: Proxmox ответил status=%s, auth=%s, message=%s", exc.status, auth_kind, exc.message, ) await websocket.close(code=1011, reason=f"Proxmox {exc.status}") except Exception as exc: # noqa: BLE001 logger.exception("console_ws: ошибка подключения к Proxmox") await websocket.close(code=1011, reason=str(exc)[:120]) finally: if upstream is not None: try: await upstream.close() except Exception: pass if session is not None: try: await session.close() except Exception: pass