Обновление файла
This commit is contained in:
@@ -1,3 +1,120 @@
|
|||||||
|
"""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:
|
||||||
|
- PVE может вернуть HTTP redirect (302) на `https://...` вместо `wss://...`
|
||||||
|
при WebSocket-handshake. Библиотека `websockets` не умеет следовать за
|
||||||
|
такими redirect (https ≠ ws/wss), поэтому перехватываем redirect
|
||||||
|
и переписываем scheme.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
|
from typing import Optional, Tuple
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
import websockets
|
||||||
|
from websockets.http11 import Response
|
||||||
|
from fastapi import APIRouter, Query, WebSocket, WebSocketDisconnect
|
||||||
|
|
||||||
|
from ..config import settings
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/console", tags=["console"])
|
||||||
|
|
||||||
|
|
||||||
|
def _process_redirect(request, response: Response) -> Optional[str]:
|
||||||
|
"""Перехватывает HTTP-redirect от Proxmox и переписывает https://→wss://.
|
||||||
|
|
||||||
|
Proxmox иногда возвращает Location со схемой https:// (хотя для WebSocket
|
||||||
|
положено wss://). Библиотека websockets в этом случае падает с
|
||||||
|
InvalidURI("scheme isn't ws or wss"). Возвращаем исправленный URL.
|
||||||
|
"""
|
||||||
|
location = response.headers.get("Location", "")
|
||||||
|
if not location:
|
||||||
|
return None
|
||||||
|
if location.startswith("https://"):
|
||||||
|
return "wss://" + location[len("https://"):]
|
||||||
|
if location.startswith("http://"):
|
||||||
|
return "ws://" + location[len("http://"):]
|
||||||
|
return location
|
||||||
|
|
||||||
|
|
||||||
|
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://", "")
|
||||||
|
upstream_url = (
|
||||||
|
f"wss://{pve_host_only}/api2/json/nodes/{node}/{guest_path}/{vmid}/vncwebsocket"
|
||||||
|
f"?port={port}&vncticket={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:
|
try:
|
||||||
# extra_headers (а не additional_headers) — последний не поддерживается
|
# extra_headers (а не additional_headers) — последний не поддерживается
|
||||||
# в uvloop, который использует uvicorn[standard].
|
# в uvloop, который использует uvicorn[standard].
|
||||||
@@ -8,3 +125,39 @@
|
|||||||
ssl=None if settings.pve_verify_ssl else False,
|
ssl=None if settings.pve_verify_ssl else False,
|
||||||
process_redirect=_process_redirect,
|
process_redirect=_process_redirect,
|
||||||
) as upstream:
|
) 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])
|
||||||
|
|||||||
Reference in New Issue
Block a user