Обновление файла
This commit is contained in:
+36
-211
@@ -1,168 +1,18 @@
|
|||||||
"""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 (например, pve1.input.netcraze.pro — тот, что в сертификате).
|
|
||||||
Если backend работает внутри сети и должен стучаться на внутренний
|
|
||||||
IP (192.168.31.4), нужно заменить hostname в Location обратно.
|
|
||||||
"""
|
|
||||||
|
|
||||||
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 и нежелательные хосты.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
location: значение заголовка Location из Proxmox.
|
|
||||||
fallback_host: хост, на который нужно стучаться по сети
|
|
||||||
(например, 192.168.31.4:8006). Если в location хост
|
|
||||||
отличается — он будет заменён.
|
|
||||||
"""
|
|
||||||
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. Если хост в редиректе — не тот, через который мы работаем
|
|
||||||
# (например, Proxmox отдаёт свой публичный DNS, а backend сидит
|
|
||||||
# внутри сети), заменяем host:port на fallback_host.
|
|
||||||
parsed = urlparse(location)
|
|
||||||
if parsed.hostname:
|
|
||||||
if ":" in fallback_host:
|
|
||||||
fb_hostname, _, fb_port = fallback_host.partition(":")
|
|
||||||
else:
|
|
||||||
fb_hostname, fb_port = fallback_host, ""
|
|
||||||
|
|
||||||
# Если хост в location не совпадает с нашим (например, Proxmox
|
|
||||||
# отдал свой публичный DNS) — заменяем на наш внутренний.
|
|
||||||
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.
|
|
||||||
|
|
||||||
Возвращает кортеж (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 получен (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"
|
|
||||||
# Хост, через который backend реально ходит к Proxmox внутри сети.
|
|
||||||
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_port = quote(str(port), safe="")
|
|
||||||
upstream_url = (
|
|
||||||
f"wss://{fallback_host}/api2/json/nodes/{node}/{guest_path}/{vmid}/vncwebsocket"
|
|
||||||
f"?port={safe_port}&vncticket={safe_ticket}"
|
|
||||||
)
|
|
||||||
|
|
||||||
# Определяем способ аутентификации.
|
|
||||||
# Cookie-вариант приоритетнее — Proxmox 8.x/9.x часто отказывает API-токену
|
|
||||||
# на WebSocket-эндпоинте vncwebsocket.
|
|
||||||
use_cookie = bool(settings.pve_username and settings.pve_password)
|
|
||||||
headers: dict = {}
|
|
||||||
csrf_token: Optional[str] = None
|
|
||||||
auth_kind = "api-token"
|
|
||||||
if use_cookie:
|
|
||||||
auth = await _get_pve_auth_cookie()
|
|
||||||
if auth:
|
|
||||||
cookie_header, csrf_token = auth
|
|
||||||
headers["Cookie"] = cookie_header
|
|
||||||
if csrf_token:
|
|
||||||
headers["CSRFPreventionToken"] = csrf_token
|
|
||||||
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
|
upstream: Optional[aiohttp.ClientWebSocketResponse] = None
|
||||||
session: Optional[ClientSession] = None
|
session: Optional[ClientSession] = None
|
||||||
try:
|
try:
|
||||||
|
# Небольшая задержка — Proxmox API иногда возвращает внутреннюю
|
||||||
|
# ошибку "name '_client' is not defined" при слишком быстрых
|
||||||
|
# последовательных запросах (race в API). 1 секунды обычно хватает.
|
||||||
|
if auth_kind == "cookie":
|
||||||
|
await asyncio.sleep(1.0)
|
||||||
|
|
||||||
session = ClientSession()
|
session = ClientSession()
|
||||||
|
|
||||||
|
# Пробуем WebSocket-handshake с retry на случай race condition в Proxmox API.
|
||||||
|
last_exc: Optional[Exception] = None
|
||||||
|
for attempt in range(1, 4):
|
||||||
|
try:
|
||||||
resp = await session.get(
|
resp = await session.get(
|
||||||
upstream_url,
|
upstream_url,
|
||||||
headers=headers,
|
headers=headers,
|
||||||
@@ -170,7 +20,8 @@ async def console_ws(
|
|||||||
ssl=False if not settings.pve_verify_ssl else None,
|
ssl=False if not settings.pve_verify_ssl else None,
|
||||||
)
|
)
|
||||||
logger.info(
|
logger.info(
|
||||||
"console_ws: initial GET status=%s, location=%s, body=%s",
|
"console_ws: attempt=%s initial GET status=%s, location=%s, body=%s",
|
||||||
|
attempt,
|
||||||
resp.status,
|
resp.status,
|
||||||
resp.headers.get("Location", "<none>"),
|
resp.headers.get("Location", "<none>"),
|
||||||
(await resp.text())[:200],
|
(await resp.text())[:200],
|
||||||
@@ -180,17 +31,39 @@ async def console_ws(
|
|||||||
upstream_url = _rewrite_redirect(location, fallback_host)
|
upstream_url = _rewrite_redirect(location, fallback_host)
|
||||||
logger.info("console_ws: rewritten URL to %s", upstream_url)
|
logger.info("console_ws: rewritten URL to %s", upstream_url)
|
||||||
await resp.release()
|
await resp.release()
|
||||||
|
break
|
||||||
elif resp.status == 101:
|
elif resp.status == 101:
|
||||||
await resp.release()
|
await resp.release()
|
||||||
|
break
|
||||||
|
elif resp.status == 502 and "name '_client' is not defined" in (await resp.text()):
|
||||||
|
# Известная внутренняя ошибка Proxmox — подождём и повторим.
|
||||||
|
await resp.release()
|
||||||
|
logger.warning(
|
||||||
|
"console_ws: Proxmox вернул race condition 502, "
|
||||||
|
"попытка %s/3 через 2с", attempt,
|
||||||
|
)
|
||||||
|
if attempt < 3:
|
||||||
|
await asyncio.sleep(2.0)
|
||||||
|
continue
|
||||||
else:
|
else:
|
||||||
|
body = await resp.text()
|
||||||
await resp.release()
|
await resp.release()
|
||||||
raise aiohttp.ClientResponseError(
|
raise aiohttp.ClientResponseError(
|
||||||
request_info=resp.request_info,
|
request_info=resp.request_info,
|
||||||
history=resp.history,
|
history=resp.history,
|
||||||
status=resp.status,
|
status=resp.status,
|
||||||
message=f"unexpected status {resp.status}",
|
message=body[:120],
|
||||||
headers=resp.headers,
|
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 = await session.ws_connect(
|
||||||
upstream_url,
|
upstream_url,
|
||||||
@@ -201,51 +74,3 @@ async def console_ws(
|
|||||||
max_msg_size=8 * 1024 * 1024,
|
max_msg_size=8 * 1024 * 1024,
|
||||||
)
|
)
|
||||||
logger.info("console_ws: upstream connected")
|
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
|
|
||||||
|
|||||||
Reference in New Issue
Block a user