diff --git a/backend/app/routers/console.py b/backend/app/routers/console.py index 7af4887..8000bf4 100644 --- a/backend/app/routers/console.py +++ b/backend/app/routers/console.py @@ -1,18 +1,19 @@ """WebSocket-прокси для VNC-консоли. -Берёт у Proxmox одноразовый тикет через `vncproxy` (это работает с API-токеном), -затем проксирует бинарный поток от браузера к WebSocket-эндпоинту Proxmox. - -В зависимости от версии Proxmox WebSocket-эндпоинт может требовать: - 1. Заголовок `Authorization: PVEAPIToken=...` (новые версии PVE ≥ 7.x) - 2. Cookie `PVEAuthCookie` (старые версии или строгие настройки) - -В консоль пишется подробный лог, чтобы было видно, на каком этапе отваливается. +Поток данных: + 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 @@ -23,6 +24,37 @@ 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, @@ -32,30 +64,31 @@ async def console_ws( port: int = Query(...), ticket: str = Query(...), ): - """ - Проксирует бинарный VNC-поток между браузером клиента и WebSocket-эндпоинтом - Proxmox (nodes/{node}/(qemu|lxc)/{vmid}/vncwebsocket). - - Если Proxmox отказывает в аутентификации — см. README, раздел «Консоль VNC». - Возможные варианты: обновить PVE, либо задать PVE_USERNAME/PVE_PASSWORD в .env - для cookie-авторизации через POST /access/ticket. - """ + """Проксирует бинарный VNC-поток между браузером и Proxmox.""" await websocket.accept() guest_path = "qemu" if guest_type == "vm" else "lxc" - pve_host = settings.pve_host.replace("http://", "").replace("https://", "") + pve_host_only = settings.pve_host.replace("http://", "").replace("https://", "") upstream_url = ( - f"wss://{pve_host}/api2/json/nodes/{node}/{guest_path}/{vmid}/vncwebsocket" + f"wss://{pve_host_only}/api2/json/nodes/{node}/{guest_path}/{vmid}/vncwebsocket" f"?port={port}&vncticket={ticket}" ) - headers = { - "Authorization": f"PVEAPIToken={settings.pve_token_name}={settings.pve_token_value}" - } + # Определяем способ аутентификации. + # 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: подключение к %s (node=%s, vmid=%s, guest=%s, port=%s)", - pve_host, node, vmid, guest_type, port, + "console_ws: connecting node=%s vmid=%s guest=%s port=%s auth=%s", + node, vmid, guest_type, port, auth_kind, ) try: @@ -73,7 +106,7 @@ async def console_ws( data = await websocket.receive_bytes() await upstream.send(data) except WebSocketDisconnect: - logger.info("console_ws: клиент отключился") + logger.info("console_ws: client disconnected") except Exception as exc: logger.warning("console_ws: client_to_upstream error: %s", exc) @@ -84,20 +117,20 @@ async def console_ws( message = message.encode() await websocket.send_bytes(message) except websockets.ConnectionClosed as exc: - logger.info("console_ws: upstream closed: %s", 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: - # Proxmox отверг соединение — скорее всего из-за аутентификации. + status = exc.response.status_code if exc.response else "?" logger.error( - "console_ws: Proxmox отклонил подключение (status=%s). " - "Возможно, требуется cookie PVEAuthCookie вместо API-токена — " - "задайте PVE_USERNAME и PVE_PASSWORD в backend/.env", - exc.response.status_code if exc.response else "?", + "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: {exc.response.status_code if exc.response else exc}") + 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])