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

This commit is contained in:
2026-07-25 00:16:50 +03:00
parent 968e902581
commit beecb75ee7
+154 -1
View File
@@ -1,3 +1,120 @@
"""WebSocket-прокси для VNC-консоли.
Поток данных:
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.
Особенности Proxmox:
- PVE может вернуть HTTP redirect (302) на `https://...` вместо `wss://...`
при WebSocket-handshake. Библиотека `websockets` не умеет следовать за
такими redirect (https ≠ ws/wss), поэтому перехватываем redirect
и переписываем scheme.
"""
import asyncio
import logging
from typing import Optional, Tuple
import httpx
import websockets
from websockets.http11 import Response
from fastapi import APIRouter, Query, WebSocket, WebSocketDisconnect
from ..config import settings
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/console", tags=["console"])
def _process_redirect(request, response: Response) -> Optional[str]:
"""Перехватывает HTTP-redirect от Proxmox и переписывает https://→wss://.
Proxmox иногда возвращает Location со схемой https:// (хотя для WebSocket
положено wss://). Библиотека websockets в этом случае падает с
InvalidURI("scheme isn't ws or wss"). Возвращаем исправленный URL.
"""
location = response.headers.get("Location", "")
if not location:
return None
if location.startswith("https://"):
return "wss://" + location[len("https://"):]
if location.startswith("http://"):
return "ws://" + location[len("http://"):]
return location
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,
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://", "")
upstream_url = (
f"wss://{pve_host_only}/api2/json/nodes/{node}/{guest_path}/{vmid}/vncwebsocket"
f"?port={port}&vncticket={ticket}"
)
# Определяем способ аутентификации.
# Cookie-вариант приоритетнее, т.к. старые/строгие PVE не принимают API-токен на WS.
headers: list = []
auth = await _get_pve_auth_cookie()
if auth:
cookie_header, _ = auth
headers.append(("Cookie", cookie_header))
auth_kind = "cookie"
else:
headers.append(("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",
node, vmid, guest_type, port, auth_kind,
)
try: try:
# extra_headers (а не additional_headers) — последний не поддерживается # extra_headers (а не additional_headers) — последний не поддерживается
# в uvloop, который использует uvicorn[standard]. # в uvloop, который использует uvicorn[standard].
@@ -7,4 +124,40 @@
subprotocols=["binary"], subprotocols=["binary"],
ssl=None if settings.pve_verify_ssl else False, ssl=None if settings.pve_verify_ssl else False,
process_redirect=_process_redirect, process_redirect=_process_redirect,
) as upstream: ) as upstream:
logger.info("console_ws: upstream connected")
async def client_to_upstream():
try:
while True:
data = await websocket.receive_bytes()
await upstream.send(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 message in upstream:
if isinstance(message, str):
message = message.encode()
await websocket.send_bytes(message)
except websockets.ConnectionClosed as 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:
status = exc.response.status_code if exc.response else "?"
logger.error(
"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: {status}")
except Exception as exc: # noqa: BLE001
logger.exception("console_ws: ошибка подключения к Proxmox")
await websocket.close(code=1011, reason=str(exc)[:120])