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

This commit is contained in:
2026-07-25 02:54:34 +03:00
parent 3c33212c0e
commit c80d3e0c54
+255 -13
View File
@@ -1,3 +1,115 @@
"""WebSocket-прокси для VNC-консоли.
Поток данных:
1. Backend получает от Proxmox одноразовый тикет через `vncproxy` (REST API).
2. Открывает WebSocket к Proxmox с этим тикетом и портом. Аутентификация:
- API-токен (заголовок `Authorization: PVEAPIToken=...`) — новые PVE ≥ 7.x
- Cookie PVEAuthCookie + CSRFPreventionToken — если заданы PVE_USERNAME + PVE_PASSWORD
3. Проксирует бинарный VNC-поток между браузером клиента и Proxmox.
Особенности Proxmox 8.x+:
- WebSocket-эндпоинт vncwebsocket возвращает 302 Redirect с Location со
схемой https:// вместо wss://. Обрабатываем редирект вручную.
- На WebSocket Proxmox может требовать одновременно Cookie и CSRF-токен.
- Proxmox в Location редиректа часто возвращает свой self-reported
origin. Если хост в Location не совпадает с тем, через который
работает backend — заменяем его.
- Известна race condition в Proxmox API "name '_client' is not defined"
при быстрых последовательных запросах (cookie + websocket). Делаем
задержку и retry.
"""
import asyncio
import logging
from typing import Optional, Tuple
from urllib.parse import quote, urlparse, urlunparse
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"])
def _rewrite_redirect(location: str, fallback_host: str) -> str:
"""Переписывает URL редиректа: заменяет https→wss и нежелательные хосты."""
if not location:
return location
# 1. ws/wss вместо http/https.
if location.startswith("https://"):
location = "wss://" + location[len("https://"):]
elif location.startswith("http://"):
location = "ws://" + location[len("http://"):]
# 2. Заменяем хост, если он не совпадает с нашим.
parsed = urlparse(location)
if parsed.hostname:
if ":" in fallback_host:
fb_hostname, _, fb_port = fallback_host.partition(":")
else:
fb_hostname, fb_port = fallback_host, ""
if parsed.hostname != fb_hostname:
new_netloc = fb_hostname
if fb_port:
new_netloc = f"{fb_hostname}:{fb_port}"
location = urlunparse(parsed._replace(netloc=new_netloc))
return location
async def _get_pve_auth_cookie() -> Optional[Tuple[str, str]]:
"""Аутентифицируется в Proxmox по логину/паролю через REST API."""
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 получен (len=%d, csrf_prefix=%s)",
len(cookie), (csrf or "")[:8],
)
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_backend_host = settings.pve_host.replace("http://", "").replace("https://", "").split(":")[0]
pve_backend_port = settings.pve_host.replace("http://", "").replace("https://", "").split(":")[-1]
if not pve_backend_port.isdigit():
pve_backend_port = "8006"
fallback_host = f"{pve_backend_host}:{pve_backend_port}"
safe_ticket = quote(ticket, safe="") safe_ticket = quote(ticket, safe="")
safe_port = quote(str(port), safe="") safe_port = quote(str(port), safe="")
upstream_url = ( upstream_url = (
@@ -5,16 +117,146 @@
f"?port={safe_port}&vncticket={safe_ticket}" f"?port={safe_port}&vncticket={safe_ticket}"
) )
# Хост для заголовка Host в WebSocket-handshake. Proxmox проверяет, # Аутентификация.
# что origin тикета совпадает с origin запроса. Если backend подключается use_cookie = bool(settings.pve_username and settings.pve_password)
# по внутреннему IP, но в сертификате Proxmox указан внешний DNS — headers: dict = {}
# можно в заголовке Host передать внешний DNS. Тогда Proxmox примет csrf_token: Optional[str] = None
# запрос как "пришедший с правильного origin". auth_kind = "api-token"
pve_external_host = settings.pve_public_host.split(":")[0] if settings.pve_public_host else "" if use_cookie:
if not pve_external_host: auth = await _get_pve_auth_cookie()
# По умолчанию используем pve1.input.netcraze.pro, если в настройках if auth:
# backend-окружения не указан PVE_PUBLIC_HOST. cookie_header, csrf_token = auth
# Это эвристика: если запросы через внешний DNS не работают, headers["Cookie"] = cookie_header
# можно задать PVE_PUBLIC_HOST=192.168.31.4 чтобы отключить подмену. if csrf_token:
pve_external_host = "" headers["CSRFPreventionToken"] = csrf_token
upstream_host_header = pve_external_host or pve_backend_host auth_kind = "cookie"
else:
logger.warning("console_ws: cookie не получен, fallback на API-токен")
if auth_kind == "api-token":
headers["Authorization"] = (
f"PVEAPIToken={settings.pve_token_name}={settings.pve_token_value}"
)
logger.info(
"console_ws: connecting node=%s vmid=%s guest=%s port=%s auth=%s ticket_prefix=%s",
node, vmid, guest_type, port, auth_kind, safe_ticket[:24],
)
upstream: Optional[aiohttp.ClientWebSocketResponse] = None
session: Optional[ClientSession] = None
try:
if auth_kind == "cookie":
await asyncio.sleep(1.0)
session = ClientSession()
last_exc: Optional[Exception] = None
for attempt in range(1, 4):
try:
resp = await session.get(
upstream_url,
headers=headers,
allow_redirects=False,
ssl=False if not settings.pve_verify_ssl else None,
)
body = await resp.text()
logger.info(
"console_ws: attempt=%s initial GET status=%s, location=%s, body=%s",
attempt,
resp.status,
resp.headers.get("Location", "<none>"),
body[:200],
)
if resp.status in (301, 302, 303, 307, 308):
location = resp.headers.get("Location", "")
upstream_url = _rewrite_redirect(location, fallback_host)
logger.info("console_ws: rewritten URL to %s", upstream_url)
await resp.release()
break
elif resp.status == 101:
await resp.release()
break
elif resp.status >= 500 and "name '_client' is not defined" in body:
await resp.release()
logger.warning(
"console_ws: Proxmox race condition, попытка %s/3 через 2с",
attempt,
)
if attempt < 3:
await asyncio.sleep(2.0)
continue
else:
await resp.release()
raise aiohttp.ClientResponseError(
request_info=resp.request_info,
history=resp.history,
status=resp.status,
message=body[:120],
headers=resp.headers,
)
except Exception as exc:
last_exc = exc
logger.warning("console_ws: attempt %s ошибка: %s", attempt, exc)
if attempt < 3:
await asyncio.sleep(2.0)
continue
else:
raise last_exc or RuntimeError("не удалось открыть WebSocket к Proxmox")
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