diff --git a/backend/app/routers/console.py b/backend/app/routers/console.py index 664d90a..b2f3901 100644 --- a/backend/app/routers/console.py +++ b/backend/app/routers/console.py @@ -1,3 +1,120 @@ +"""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. + +Особенности Proxmox: + - PVE может вернуть HTTP redirect (302) на `https://...` вместо `wss://...` + при WebSocket-handshake. Библиотека `websockets` не умеет следовать за + такими redirect (https ≠ ws/wss), поэтому перехватываем redirect + и переписываем scheme. +""" + +import asyncio +import logging +from typing import Optional, Tuple + +import httpx +import websockets +from websockets.http11 import Response +from fastapi import APIRouter, Query, WebSocket, WebSocketDisconnect + +from ..config import settings + +logger = logging.getLogger(__name__) + +router = APIRouter(prefix="/console", tags=["console"]) + + +def _process_redirect(request, response: Response) -> Optional[str]: + """Перехватывает HTTP-redirect от Proxmox и переписывает https://→wss://. + + Proxmox иногда возвращает Location со схемой https:// (хотя для WebSocket + положено wss://). Библиотека websockets в этом случае падает с + InvalidURI("scheme isn't ws or wss"). Возвращаем исправленный URL. + """ + location = response.headers.get("Location", "") + if not location: + return None + if location.startswith("https://"): + return "wss://" + location[len("https://"):] + if location.startswith("http://"): + return "ws://" + location[len("http://"):] + return location + + +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: list = [] + auth = await _get_pve_auth_cookie() + if auth: + cookie_header, _ = auth + headers.append(("Cookie", cookie_header)) + auth_kind = "cookie" + else: + headers.append(("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: # extra_headers (а не additional_headers) — последний не поддерживается # в uvloop, который использует uvicorn[standard]. @@ -7,4 +124,40 @@ subprotocols=["binary"], ssl=None if settings.pve_verify_ssl else False, process_redirect=_process_redirect, - ) as upstream: \ No newline at end of file + ) 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])