Files
Proxmox-VPS-Panel/backend/app/routers/console.py
T
2026-07-25 00:31:27 +03:00

151 lines
6.8 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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 8.x+ vncticket может содержать спецсимволы (#, /, %, :),
которые ломают парсинг URL (RFC 3986). Кодируем через quote().
- process_redirect в websockets не работает на uvloop (используется
uvicorn[standard]), поэтому редиректы не обрабатываем — Proxmox
обычно их и не делает при корректном URL.
"""
import asyncio
import logging
from typing import Optional, Tuple
from urllib.parse import quote
import httpx
import websockets
from fastapi import APIRouter, Query, WebSocket, WebSocketDisconnect
from ..config import settings
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,
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://", "")
# quote() экранирует спецсимволы в тикете и порте по правилам RFC 3986.
# Без этого в Proxmox 8.x+ vncticket может содержать # / % : которые ломают URL.
safe_ticket = quote(ticket, safe="")
safe_port = quote(str(port), safe="")
upstream_url = (
f"wss://{pve_host_only}/api2/json/nodes/{node}/{guest_path}/{vmid}/vncwebsocket"
f"?port={safe_port}&vncticket={safe_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:
# extra_headers (а не additional_headers) — последний не поддерживается
# в uvloop, который использует uvicorn[standard].
async with websockets.connect(
upstream_url,
extra_headers=headers,
subprotocols=["binary"],
ssl=None if settings.pve_verify_ssl else False,
) 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])