"""WebSocket-прокси для VNC-консоли. Поток данных: 1. Backend получает от Proxmox одноразовый тикет через `vncproxy` (REST API, работает с API-токеном). 2. Открывает WebSocket к Proxmox с этим тикетом и портом. Аутентификация: - API-токен (заголовок `Authorization: PVEAPIToken=...`) — новые PVE ≥ 7.x - Cookie PVEAuthCookie — если заданы PVE_USERNAME + PVE_PASSWORD 3. Проксирует бинарный VNC-поток между браузером клиента и Proxmox. """ import asyncio import logging from typing import Optional, Tuple import httpx import websockets 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 по логину/паролю и возвращает (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") # в Proxmox поле называется "ticket" csrf = data.get("CSRFPreventionToken") if not cookie: logger.error("auth: Proxmox не вернул PVEAuthCookie (пустой ответ)") return None # Для WebSocket-эндпоинта vncwebsocket достаточно PVEAuthCookie. cookie_header = f"PVEAuthCookie={cookie}" logger.info("auth: PVEAuthCookie получен успешно") return cookie_header, 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://", "") upstream_url = ( f"wss://{pve_host_only}/api2/json/nodes/{node}/{guest_path}/{vmid}/vncwebsocket" f"?port={port}&vncticket={ticket}" ) # Определяем способ аутентификации. # Cookie-вариант приоритетнее, т.к. старые/строгие PVE не принимают API-токен на WS. 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, ) try: async with websockets.connect( upstream_url, extra_headers=headers, subprotocols=["binary"], ssl=None if settings.pve_verify_ssl else False, ) as upstream: logger.info("console_ws: upstream connected") async def client_to_upstream(): try: while True: data = await websocket.receive_bytes() await upstream.send(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 message in upstream: if isinstance(message, str): message = message.encode() await websocket.send_bytes(message) except websockets.ConnectionClosed as exc: logger.info("console_ws: upstream closed: code=%s", exc.code) except Exception as exc: logger.warning("console_ws: upstream_to_client error: %s", exc) await asyncio.gather(client_to_upstream(), upstream_to_client()) except websockets.exceptions.InvalidStatus as exc: status = exc.response.status_code if exc.response else "?" logger.error( "console_ws: Proxmox отклонил подключение (status=%s, auth=%s). " "Если 401 — задайте PVE_USERNAME/PVE_PASSWORD в backend/.env " "для cookie-аутентификации.", status, auth_kind, ) await websocket.close(code=1011, reason=f"Proxmox auth failed: {status}") except Exception as exc: # noqa: BLE001 logger.exception("console_ws: ошибка подключения к Proxmox") await websocket.close(code=1011, reason=str(exc)[:120])