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

This commit is contained in:
2026-07-24 23:09:16 +03:00
parent d99dc5b5ac
commit f1c1833eca
+64 -31
View File
@@ -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])