diff --git a/backend/app/routers/console.py b/backend/app/routers/console.py index cbd96d7..350b74e 100644 --- a/backend/app/routers/console.py +++ b/backend/app/routers/console.py @@ -4,15 +4,15 @@ 1. Backend получает от Proxmox одноразовый тикет через `vncproxy` (REST API). 2. Открывает WebSocket к Proxmox с этим тикетом и портом. Аутентификация: - API-токен (заголовок `Authorization: PVEAPIToken=...`) — новые PVE ≥ 7.x - - Cookie PVEAuthCookie — если заданы PVE_USERNAME + PVE_PASSWORD + - Cookie PVEAuthCookie + CSRFPreventionToken — если заданы PVE_USERNAME + PVE_PASSWORD 3. Проксирует бинарный VNC-поток между браузером клиента и Proxmox. Особенности Proxmox 8.x+: - - При WebSocket-handshake на vncwebsocket Proxmox отвечает 302 Redirect - на тот же URL, но со схемой https:// вместо wss://. aiohttp при - перенаправлении теряет Cookie-заголовок (или не может следовать за - редиректом без TLS-валидации). Поэтому делаем редирект вручную: - перехватываем ответ, переписываем scheme, открываем WS сами. + - WebSocket-эндпоинт vncwebsocket возвращает 302 Redirect с Location со + схемой https:// вместо wss://. Обрабатываем редирект вручную, + чтобы сохранить Cookie и переписать scheme. + - На WebSocket Proxmox требует одновременно Cookie и CSRF-токен + (только для POST/PUT/DELETE, но Proxmox иногда проверяет и для WS-handshake). """ import asyncio @@ -55,7 +55,10 @@ async def _get_pve_auth_cookie() -> Optional[Tuple[str, str]]: if not cookie: logger.error("auth: Proxmox не вернул PVEAuthCookie") return None - logger.info("auth: PVEAuthCookie получен успешно") + 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") @@ -85,10 +88,15 @@ async def console_ws( ) headers: dict = {} + csrf_token: Optional[str] = None auth = await _get_pve_auth_cookie() if auth: - cookie_header, _ = 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"] = ( @@ -97,34 +105,38 @@ async def console_ws( 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, + "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 - # с сохранением Cookie-заголовка. + # Отключаем автоматический 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", "") - logger.info("console_ws: redirect to %s", 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: - # Proxmox уже сделал WS-handshake (редкий случай без редиректа). await resp.release() else: await resp.release()