169 lines
6.9 KiB
Python
169 lines
6.9 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.
|
||
|
||
Используется aiohttp вместо websockets, потому что:
|
||
- Proxmox 8.x+ делает 302 Redirect с Location со схемой https:// вместо wss://,
|
||
а websockets на uvloop не умеет правильно следовать за такими редиректами
|
||
(raise InvalidURI "scheme isn't ws or wss").
|
||
- aiohttp корректно обрабатывает HTTP→WS-редиректы с заменой scheme.
|
||
"""
|
||
|
||
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://", "")
|
||
|
||
# Экранируем спецсимволы в тикете/порте (vncticket может содержать # / % :).
|
||
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-токен на WebSocket).
|
||
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:
|
||
# aiohttp сам следует за HTTP-редиректами и при этом корректно
|
||
# меняет схему с https на wss (что websockets на uvloop не делает).
|
||
session = ClientSession()
|
||
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
|