diff --git a/backend/app/routers/console.py b/backend/app/routers/console.py index 88e97c0..06af8ff 100644 --- a/backend/app/routers/console.py +++ b/backend/app/routers/console.py @@ -1,116 +1,3 @@ -"""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://. Обрабатываем редирект вручную. - - На WebSocket Proxmox может требовать одновременно Cookie и CSRF-токен. - - Proxmox в Location редиректа часто возвращает свой self-reported - origin (например, pve1.input.netcraze.pro — тот, что в сертификате). - Если backend работает внутри сети и должен стучаться на внутренний - IP (192.168.31.4), нужно заменить hostname в Location обратно. - - Известна race condition в Proxmox API "name '_client' is not defined" - при быстрых последовательных запросах (cookie + websocket). Делаем - задержку и retry. -""" - -import asyncio -import logging -from typing import Optional, Tuple -from urllib.parse import quote, urlparse, urlunparse - -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"]) - - -def _rewrite_redirect(location: str, fallback_host: str) -> str: - """Переписывает URL редиректа: заменяет https→wss и нежелательные хосты.""" - if not location: - return location - - # 1. ws/wss вместо http/https. - if location.startswith("https://"): - location = "wss://" + location[len("https://"):] - elif location.startswith("http://"): - location = "ws://" + location[len("http://"):] - - # 2. Заменяем хост, если он не совпадает с нашим. - parsed = urlparse(location) - if parsed.hostname: - if ":" in fallback_host: - fb_hostname, _, fb_port = fallback_host.partition(":") - else: - fb_hostname, fb_port = fallback_host, "" - if parsed.hostname != fb_hostname: - new_netloc = fb_hostname - if fb_port: - new_netloc = f"{fb_hostname}:{fb_port}" - location = urlunparse(parsed._replace(netloc=new_netloc)) - return location - - -async def _get_pve_auth_cookie() -> Optional[Tuple[str, str]]: - """Аутентифицируется в Proxmox по логину/паролю через REST API.""" - 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_backend_host = settings.pve_host.replace("http://", "").replace("https://", "").split(":")[0] - pve_backend_port = settings.pve_host.replace("http://", "").replace("https://", "").split(":")[-1] - if not pve_backend_port.isdigit(): - pve_backend_port = "8006" - fallback_host = f"{pve_backend_host}:{pve_backend_port}" - safe_ticket = quote(ticket, safe="") safe_port = quote(str(port), safe="") upstream_url = ( @@ -118,151 +5,16 @@ async def console_ws( f"?port={safe_port}&vncticket={safe_ticket}" ) - # Аутентификация. - use_cookie = bool(settings.pve_username and settings.pve_password) - headers: dict = {} - csrf_token: Optional[str] = None - auth_kind = "api-token" - if use_cookie: - 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: - logger.warning("console_ws: cookie не получен, fallback на API-токен") - if auth_kind == "api-token": - headers["Authorization"] = ( - f"PVEAPIToken={settings.pve_token_name}={settings.pve_token_value}" - ) - - 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: - # Небольшая задержка — Proxmox API иногда возвращает внутреннюю - # ошибку "name '_client' is not defined" при слишком быстрых - # последовательных запросах (race в API). - if auth_kind == "cookie": - await asyncio.sleep(1.0) - - session = ClientSession() - - # Retry на случай race condition в Proxmox API. - last_exc: Optional[Exception] = None - for attempt in range(1, 4): - try: - resp = await session.get( - upstream_url, - headers=headers, - allow_redirects=False, - ssl=False if not settings.pve_verify_ssl else None, - ) - body = await resp.text() - logger.info( - "console_ws: attempt=%s initial GET status=%s, location=%s, body=%s", - attempt, - resp.status, - resp.headers.get("Location", ""), - body[:200], - ) - if resp.status in (301, 302, 303, 307, 308): - location = resp.headers.get("Location", "") - upstream_url = _rewrite_redirect(location, fallback_host) - logger.info("console_ws: rewritten URL to %s", upstream_url) - await resp.release() - break - elif resp.status == 101: - await resp.release() - break - elif resp.status >= 500 and "name '_client' is not defined" in body: - # Известная внутренняя ошибка Proxmox — подождём и повторим. - await resp.release() - logger.warning( - "console_ws: Proxmox race condition, попытка %s/3 через 2с", - attempt, - ) - if attempt < 3: - await asyncio.sleep(2.0) - continue - else: - await resp.release() - raise aiohttp.ClientResponseError( - request_info=resp.request_info, - history=resp.history, - status=resp.status, - message=body[:120], - headers=resp.headers, - ) - except Exception as exc: - last_exc = exc - logger.warning("console_ws: attempt %s ошибка: %s", attempt, exc) - if attempt < 3: - await asyncio.sleep(2.0) - continue - else: - raise last_exc or RuntimeError("не удалось открыть WebSocket к Proxmox") - - 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 + # Хост для заголовка Host в WebSocket-handshake. Proxmox проверяет, + # что origin тикета совпадает с origin запроса. Если backend подключается + # по внутреннему IP, но в сертификате Proxmox указан внешний DNS — + # можно в заголовке Host передать внешний DNS. Тогда Proxmox примет + # запрос как "пришедший с правильного origin". + pve_external_host = settings.pve_public_host.split(":")[0] if settings.pve_public_host else "" + if not pve_external_host: + # По умолчанию используем pve1.input.netcraze.pro, если в настройках + # backend-окружения не указан PVE_PUBLIC_HOST. + # Это эвристика: если запросы через внешний DNS не работают, + # можно задать PVE_PUBLIC_HOST=192.168.31.4 чтобы отключить подмену. + pve_external_host = "" + upstream_host_header = pve_external_host or pve_backend_host \ No newline at end of file