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

196 lines
7.8 KiB
Python

"""WebSocket-прокси для VNC-консоли.
Поток данных:
1. Backend получает от Proxmox одноразовый тикет через `vncproxy` (REST API).
2. Открывает WebSocket к Proxmox с этим тикетом и портом. Аутентификация:
- API-токен (заголовок `Authorization: PVEAPIToken=...`) — новые PVE ≥ 7.x
- Cookie PVEAuthCookie — если заданы PVE_USERNAME + PVE_PASSWORD
3. Проксирует бинарный VNC-поток между браузером клиента и Proxmox.
Особенности Proxmox 8.x+:
- При WebSocket-handshake на vncwebsocket Proxmox отвечает 302 Redirect
на тот же URL, но со схемой https:// вместо wss://. aiohttp при
перенаправлении теряет Cookie-заголовок (или не может следовать за
редиректом без TLS-валидации). Поэтому делаем редирект вручную:
перехватываем ответ, переписываем scheme, открываем WS сами.
"""
import asyncio
import logging
from typing import Optional, Tuple
from urllib.parse import quote
import aiohttp
import httpx
from aiohttp import ClientSession, WSMsgType
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 по логину/паролю через REST API.
Возвращает кортеж (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")
csrf = data.get("CSRFPreventionToken")
if not cookie:
logger.error("auth: Proxmox не вернул PVEAuthCookie")
return None
logger.info("auth: PVEAuthCookie получен успешно")
return f"PVEAuthCookie={cookie}", 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://", "")
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}"
)
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: connecting node=%s vmid=%s guest=%s port=%s auth=%s",
node, vmid, guest_type, port, auth_kind,
)
upstream: Optional[aiohttp.ClientWebSocketResponse] = None
session: Optional[ClientSession] = None
try:
session = ClientSession()
# Отключаем автоматический redirect, чтобы самим обработать https→wss
# с сохранением Cookie-заголовка.
resp = await session.get(
upstream_url,
headers=headers,
allow_redirects=False,
ssl=False if not settings.pve_verify_ssl else None,
)
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
await resp.release()
elif resp.status == 101:
# Proxmox уже сделал WS-handshake (редкий случай без редиректа).
await resp.release()
else:
await resp.release()
raise aiohttp.ClientResponseError(
request_info=resp.request_info,
history=resp.history,
status=resp.status,
message=f"unexpected status {resp.status}",
headers=resp.headers,
)
upstream = await session.ws_connect(
upstream_url,
headers=headers,
ssl=False if not settings.pve_verify_ssl else None,
autoclose=False,
autoping=True,
max_msg_size=8 * 1024 * 1024,
)
logger.info("console_ws: upstream connected")
async def client_to_upstream():
try:
while True:
data = await websocket.receive_bytes()
await upstream.send_bytes(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 msg in upstream:
if msg.type == WSMsgType.BINARY:
await websocket.send_bytes(msg.data)
elif msg.type == WSMsgType.TEXT:
await websocket.send_bytes(msg.data.encode())
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:
logger.warning("console_ws: upstream_to_client error: %s", exc)
await asyncio.gather(client_to_upstream(), upstream_to_client())
except aiohttp.ClientResponseError as exc:
logger.error(
"console_ws: Proxmox ответил status=%s, auth=%s, message=%s",
exc.status, auth_kind, exc.message,
)
await websocket.close(code=1011, reason=f"Proxmox {exc.status}")
except Exception as exc: # noqa: BLE001
logger.exception("console_ws: ошибка подключения к Proxmox")
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