diff --git a/backend/app/routers/console.py b/backend/app/routers/console.py index c8d9d6a..7e31858 100644 --- a/backend/app/routers/console.py +++ b/backend/app/routers/console.py @@ -1,6 +1,210 @@ +"""WebSocket-прокси для VNC-консоли. + +Поток данных: + 1. Backend получает от Proxmox одноразовый тикет через `vncproxy` (REST API). + 2. Открывает WebSocket к Proxmox с этим тикетом и портом. Аутентификация: + - API-токен (заголовок `Authorization: PVEAPIToken=...`) — новые PVE ≥ 7.x + - Cookie PVEAuthCookie + CSRFPreventionToken — если заданы PVE_USERNAME + PVE_PASSWORD + 3. Проксирует бинарный VNC-поток между браузером клиента и Proxmox. + +Особенности Proxmox 8.x+: + - WebSocket-эндпоинт vncwebsocket возвращает 302 Redirect с Location со + схемой https:// вместо wss://. Обрабатываем редирект вручную, + чтобы сохранить Cookie и переписать scheme. + - На WebSocket Proxmox может требовать одновременно Cookie и CSRF-токен. + - vncticket привязан к origin (схема+хост+порт), в котором был + запрошен. Чтобы проксировать через внешний DNS, нужно передать + host= в vncproxy.post() (см. proxmox_client.py). +""" + +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 получен (len=%d, csrf_prefix=%s)", + len(cookie), (csrf or "")[:8], + ) + 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" - # По умолчанию WebSocket идёт на тот же хост, что и PVE_HOST. Но если - # vncticket был выписан для другого origin (внешнего DNS), нужно - # подключаться к этому origin — иначе Proxmox отвергнет тикет. + # По умолчанию WebSocket идёт на тот же хост, что и PVE_HOST. Если + # vncticket был выписан для внешнего origin (PVE_PUBLIC_HOST), то и + # WebSocket нужно открывать к нему — иначе Proxmox отвергнет тикет. pve_origin = settings.pve_public_host or settings.pve_host - pve_host_only = pve_origin.replace("http://", "").replace("https://", "") \ No newline at end of file + pve_host_only = pve_origin.replace("http://", "").replace("https://", "") + + 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}" + ) + + headers: dict = {} + csrf_token: Optional[str] = None + auth = await _get_pve_auth_cookie() + if auth: + cookie_header, csrf_token = auth + headers["Cookie"] = cookie_header + if csrf_token: + headers["CSRFPreventionToken"] = csrf_token + 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 ticket_prefix=%s", + node, vmid, guest_type, port, auth_kind, safe_ticket[:24], + ) + + upstream: Optional[aiohttp.ClientWebSocketResponse] = None + session: Optional[ClientSession] = None + try: + session = ClientSession() + resp = await session.get( + upstream_url, + headers=headers, + allow_redirects=False, + ssl=False if not settings.pve_verify_ssl else None, + ) + logger.info( + "console_ws: initial GET status=%s, location=%s, body=%s", + resp.status, + resp.headers.get("Location", ""), + (await resp.text())[:200], + ) + if resp.status in (301, 302, 303, 307, 308): + location = resp.headers.get("Location", "") + if location.startswith("https://"): + upstream_url = "wss://" + location[len("https://"):] + elif location.startswith("http://"): + upstream_url = "ws://" + location[len("http://"):] + else: + upstream_url = location + logger.info("console_ws: rewritten URL to %s", upstream_url) + await resp.release() + elif resp.status == 101: + await resp.release() + else: + await resp.release() + raise aiohttp.ClientResponseError( + request_info=resp.request_info, + history=resp.history, + status=resp.status, + message=f"unexpected status {resp.status}", + headers=resp.headers, + ) + + 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