From cbfbb5a10604660a776973b973f60ef4a8a621e2 Mon Sep 17 00:00:00 2001 From: host Date: Sat, 25 Jul 2026 02:02:57 +0300 Subject: [PATCH] =?UTF-8?q?=D0=9E=D0=B1=D0=BD=D0=BE=D0=B2=D0=BB=D0=B5?= =?UTF-8?q?=D0=BD=D0=B8=D0=B5=20=D1=84=D0=B0=D0=B9=D0=BB=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/app/routers/console.py | 211 +-------------------------------- 1 file changed, 5 insertions(+), 206 deletions(-) diff --git a/backend/app/routers/console.py b/backend/app/routers/console.py index 350b74e..c8d9d6a 100644 --- a/backend/app/routers/console.py +++ b/backend/app/routers/console.py @@ -1,207 +1,6 @@ -"""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-токен - (только для POST/PUT/DELETE, но Proxmox иногда проверяет и для WS-handshake). -""" - -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" - pve_host_only = settings.pve_host.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 - # Proxmox ожидает CSRF-токен в одноимённом заголовке для всех - # не-GET запросов; для WebSocket-handshake передаём на всякий случай. - 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() - # Отключаем автоматический redirect, чтобы самим обработать https→wss. - 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 + # По умолчанию WebSocket идёт на тот же хост, что и PVE_HOST. Но если + # vncticket был выписан для другого origin (внешнего DNS), нужно + # подключаться к этому origin — иначе 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