Обновление файла

This commit is contained in:
2026-07-25 01:35:16 +03:00
parent be2d63d7fe
commit cb1bbcb24b
+62 -44
View File
@@ -1,19 +1,17 @@
"""WebSocket-прокси для VNC-консоли. """WebSocket-прокси для VNC-консоли.
Поток данных: Поток данных:
1. Backend получает от Proxmox одноразовый тикет через `vncproxy` (REST API, 1. Backend получает от Proxmox одноразовый тикет через `vncproxy` (REST API).
работает с API-токеном).
2. Открывает WebSocket к Proxmox с этим тикетом и портом. Аутентификация: 2. Открывает WebSocket к Proxmox с этим тикетом и портом. Аутентификация:
- API-токен (заголовок `Authorization: PVEAPIToken=...`) — новые PVE ≥ 7.x - API-токен (заголовок `Authorization: PVEAPIToken=...`) — новые PVE ≥ 7.x
- Cookie PVEAuthCookie — если заданы PVE_USERNAME + PVE_PASSWORD - Cookie PVEAuthCookie — если заданы PVE_USERNAME + PVE_PASSWORD
3. Проксирует бинарный VNC-поток между браузером клиента и Proxmox. 3. Проксирует бинарный VNC-поток между браузером клиента и Proxmox.
Особенности: Используется aiohttp вместо websockets, потому что:
- В Proxmox 8.x+ vncticket может содержать спецсимволы (#, /, %, :), - Proxmox 8.x+ делает 302 Redirect с Location со схемой https:// вместо wss://,
которые ломают парсинг URL (RFC 3986). Кодируем через quote(). а websockets на uvloop не умеет правильно следовать за такими редиректами
- process_redirect в websockets не работает на uvloop (используется (raise InvalidURI "scheme isn't ws or wss").
uvicorn[standard]), поэтому редиректы не обрабатываем — Proxmox - aiohttp корректно обрабатывает HTTP→WS-редиректы с заменой scheme.
обычно их и не делает при корректном URL.
""" """
import asyncio import asyncio
@@ -21,8 +19,9 @@ import logging
from typing import Optional, Tuple from typing import Optional, Tuple
from urllib.parse import quote from urllib.parse import quote
import aiohttp
import httpx import httpx
import websockets from aiohttp import ClientSession, WSMsgType
from fastapi import APIRouter, Query, WebSocket, WebSocketDisconnect from fastapi import APIRouter, Query, WebSocket, WebSocketDisconnect
from ..config import settings from ..config import settings
@@ -33,9 +32,10 @@ router = APIRouter(prefix="/console", tags=["console"])
async def _get_pve_auth_cookie() -> Optional[Tuple[str, str]]: async def _get_pve_auth_cookie() -> Optional[Tuple[str, str]]:
"""Аутентифицируется в Proxmox по логину/паролю и возвращает (cookie_header, csrf_token). """Аутентифицируется в Proxmox по логину/паролю через REST API.
Возвращает None, если PVE_USERNAME/PVE_PASSWORD не заданы в настройках. Возвращает кортеж (cookie_header, csrf_token) или None,
если PVE_USERNAME/PVE_PASSWORD не заданы.
""" """
if not settings.pve_username or not settings.pve_password: if not settings.pve_username or not settings.pve_password:
return None return None
@@ -49,15 +49,13 @@ async def _get_pve_auth_cookie() -> Optional[Tuple[str, str]]:
resp = await client.post(url, data=payload) resp = await client.post(url, data=payload)
resp.raise_for_status() resp.raise_for_status()
data = resp.json().get("data", {}) data = resp.json().get("data", {})
cookie = data.get("ticket") # в Proxmox поле называется "ticket" cookie = data.get("ticket")
csrf = data.get("CSRFPreventionToken") csrf = data.get("CSRFPreventionToken")
if not cookie: if not cookie:
logger.error("auth: Proxmox не вернул PVEAuthCookie (пустой ответ)") logger.error("auth: Proxmox не вернул PVEAuthCookie")
return None return None
# Для WebSocket-эндпоинта vncwebsocket достаточно PVEAuthCookie.
cookie_header = f"PVEAuthCookie={cookie}"
logger.info("auth: PVEAuthCookie получен успешно") logger.info("auth: PVEAuthCookie получен успешно")
return cookie_header, csrf or "" return f"PVEAuthCookie={cookie}", csrf or ""
except Exception as exc: except Exception as exc:
logger.exception("auth: ошибка аутентификации в Proxmox") logger.exception("auth: ошибка аутентификации в Proxmox")
return None return None
@@ -77,8 +75,8 @@ async def console_ws(
guest_path = "qemu" if guest_type == "vm" else "lxc" guest_path = "qemu" if guest_type == "vm" else "lxc"
pve_host_only = settings.pve_host.replace("http://", "").replace("https://", "") pve_host_only = settings.pve_host.replace("http://", "").replace("https://", "")
# quote() экранирует спецсимволы в тикете и порте по правилам RFC 3986.
# Без этого в Proxmox 8.x+ vncticket может содержать # / % : которые ломают URL. # Экранируем спецсимволы в тикете/порте (vncticket может содержать # / % :).
safe_ticket = quote(ticket, safe="") safe_ticket = quote(ticket, safe="")
safe_port = quote(str(port), safe="") safe_port = quote(str(port), safe="")
upstream_url = ( upstream_url = (
@@ -86,16 +84,18 @@ async def console_ws(
f"?port={safe_port}&vncticket={safe_ticket}" f"?port={safe_port}&vncticket={safe_ticket}"
) )
# Определяем способ аутентификации. # Определяем способ аутентификации: cookie приоритетнее (некоторые PVE
# Cookie-вариант приоритетнее, т.к. старые/строгие PVE не принимают API-токен на WS. # не принимают API-токен на WebSocket).
headers: list = [] headers: dict = {}
auth = await _get_pve_auth_cookie() auth = await _get_pve_auth_cookie()
if auth: if auth:
cookie_header, _ = auth cookie_header, _ = auth
headers.append(("Cookie", cookie_header)) headers["Cookie"] = cookie_header
auth_kind = "cookie" auth_kind = "cookie"
else: else:
headers.append(("Authorization", f"PVEAPIToken={settings.pve_token_name}={settings.pve_token_value}")) headers["Authorization"] = (
f"PVEAPIToken={settings.pve_token_name}={settings.pve_token_value}"
)
auth_kind = "api-token" auth_kind = "api-token"
logger.info( logger.info(
@@ -103,22 +103,27 @@ async def console_ws(
node, vmid, guest_type, port, auth_kind, node, vmid, guest_type, port, auth_kind,
) )
upstream: Optional[aiohttp.ClientWebSocketResponse] = None
session: Optional[ClientSession] = None
try: try:
# extra_headers (а не additional_headers) — последний не поддерживается # aiohttp сам следует за HTTP-редиректами и при этом корректно
# в uvloop, который использует uvicorn[standard]. # меняет схему с https на wss (что websockets на uvloop не делает).
async with websockets.connect( session = ClientSession()
upstream = await session.ws_connect(
upstream_url, upstream_url,
extra_headers=headers, headers=headers,
subprotocols=["binary"], ssl=False if not settings.pve_verify_ssl else None,
ssl=None if settings.pve_verify_ssl else False, autoclose=False,
) as upstream: autoping=True,
max_msg_size=8 * 1024 * 1024,
)
logger.info("console_ws: upstream connected") logger.info("console_ws: upstream connected")
async def client_to_upstream(): async def client_to_upstream():
try: try:
while True: while True:
data = await websocket.receive_bytes() data = await websocket.receive_bytes()
await upstream.send(data) await upstream.send_bytes(data)
except WebSocketDisconnect: except WebSocketDisconnect:
logger.info("console_ws: client disconnected") logger.info("console_ws: client disconnected")
except Exception as exc: except Exception as exc:
@@ -126,25 +131,38 @@ async def console_ws(
async def upstream_to_client(): async def upstream_to_client():
try: try:
async for message in upstream: async for msg in upstream:
if isinstance(message, str): if msg.type == WSMsgType.BINARY:
message = message.encode() await websocket.send_bytes(msg.data)
await websocket.send_bytes(message) elif msg.type == WSMsgType.TEXT:
except websockets.ConnectionClosed as exc: await websocket.send_bytes(msg.data.encode())
logger.info("console_ws: upstream closed: code=%s", exc.code) 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: except Exception as exc:
logger.warning("console_ws: upstream_to_client error: %s", exc) logger.warning("console_ws: upstream_to_client error: %s", exc)
await asyncio.gather(client_to_upstream(), upstream_to_client()) await asyncio.gather(client_to_upstream(), upstream_to_client())
except websockets.exceptions.InvalidStatus as exc: except aiohttp.ClientResponseError as exc:
status = exc.response.status_code if exc.response else "?"
logger.error( logger.error(
"console_ws: Proxmox отклонил подключение (status=%s, auth=%s). " "console_ws: Proxmox ответил status=%s, auth=%s, message=%s",
"Если 401 — задайте PVE_USERNAME/PVE_PASSWORD в backend/.env " exc.status, auth_kind, exc.message,
"для cookie-аутентификации.",
status, auth_kind,
) )
await websocket.close(code=1011, reason=f"Proxmox auth failed: {status}") await websocket.close(code=1011, reason=f"Proxmox {exc.status}")
except Exception as exc: # noqa: BLE001 except Exception as exc: # noqa: BLE001
logger.exception("console_ws: ошибка подключения к Proxmox") logger.exception("console_ws: ошибка подключения к Proxmox")
await websocket.close(code=1011, reason=str(exc)[:120]) 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