v1.1.6: fix LXC xterm.js terminal (termproxy auth handshake)

- Add mandatory Proxmox termproxy auth: send 'USERNAME:VNCTICKET\n' as
  first WebSocket message, wait for 'OK' before starting PTY proxy
- Fix server.username → server.api_user (token suffix stripped)
- Fix CSRFPreventionToken sent in body → header only
- Fix additional_headers → extra_headers (websockets v12)
- Add URL-encoding for vncticket, PVEAuthCookie WS header, subprotocols=['binary']
- Forward initial terminal data arriving with OK frame
- Bump VERSION 1.1.5 → 1.1.6
This commit is contained in:
markmorado
2026-03-16 03:07:14 +01:00
parent 5a67981f9a
commit 4940cbe77f
8 changed files with 174 additions and 130 deletions

View File

@@ -4,6 +4,26 @@ All notable changes to PVEmanager will be documented in this file.
---
## [v1.1.6] - 2026-03-16
### 🖥️ LXC Terminal — xterm.js Console Fixed
- **Root cause identified and fixed** — Proxmox `termproxy` protocol requires a mandatory auth handshake: the client must send `"USERNAME:VNCTICKET\n"` as the very first WebSocket message; Proxmox responds with `"OK"` before starting the PTY. The backend handler was missing this step entirely, causing Proxmox to wait indefinitely and the browser to show "Disconnected".
- **Auth handshake added** — after connecting to the Proxmox `vncwebsocket` endpoint the backend now sends the auth message, validates the `"OK"` response, and only then starts the bidirectional proxy.
- **Initial terminal data forwarded** — any terminal data arriving in the same `"OK"` frame is immediately forwarded to the browser.
- **Graceful error handling** — auth timeout (>10 s) and explicit rejection close the WebSocket with a descriptive reason code instead of silently hanging.
### 🔧 Fixes
- Fixed `'ProxmoxServer' object has no attribute 'username'` — replaced `server.username` with correct `server.api_user` (with token suffix stripping)
- Fixed `CSRFPreventionToken` being sent in the request body — now sent only as an HTTP header
- Fixed `additional_headers` → `extra_headers` (websockets v12 API rename)
- Added URL-encoding for `vncticket` in the WebSocket URL (`urllib.parse.quote`)
- Added `PVEAuthCookie` header in the WebSocket upgrade request
- Added `subprotocols=["binary"]` required by Proxmox vncwebsocket
---
## [v1.1.5] - 2026-03-15
### 🗂️ Task Drawer — UPID Tracking & Clear Completed

View File

@@ -4,7 +4,7 @@
**Modern web panel for managing Proxmox servers, virtual machines and LXC containers**
[![Version](https://img.shields.io/badge/version-1.1.5-blue?style=flat-square)](CHANGELOG.md)
[![Version](https://img.shields.io/badge/version-1.1.6-blue?style=flat-square)](CHANGELOG.md)
[![License](https://img.shields.io/badge/license-MIT-green?style=flat-square)](LICENSE)
[![Docker](https://img.shields.io/badge/docker-ready-2496ED?style=flat-square&logo=docker&logoColor=white)](compose.yml)
[![Python](https://img.shields.io/badge/python-3.12-3776AB?style=flat-square&logo=python&logoColor=white)](backend/)
@@ -31,6 +31,7 @@ PVEmanager is a self-hosted web panel that provides a unified interface for mana
### Access & Console
- **VNC console** — in-browser console via noVNC, no client software required
- **xterm.js terminal** — full interactive shell directly in the browser for LXC containers
- **Remote commands** — execute bash commands via QEMU Guest Agent
- **RBAC v2** — granular role-based access control with per-resource permissions
- **Session management** — active session list, remote session revocation, login protection

View File

@@ -1 +1 @@
1.1.5
1.1.6

View File

@@ -1,6 +1,6 @@
# 📖 PVEmanager - Documentation
> Complete guide for installation, configuration and usage of PVEmanager v1.1.5
> Complete guide for installation, configuration and usage of PVEmanager v1.1.6
---

View File

@@ -2577,140 +2577,144 @@ async def terminal_websocket_fixed(
"""WebSocket терминал для LXC контейнеров через Proxmox termproxy API"""
import websockets
import httpx
from urllib.parse import quote
await websocket.accept()
logger.info(f"Terminal WebSocket accepted for container {vmid} on node {node}")
# Получаем сервер из БД
server = db.query(ProxmoxServer).filter(ProxmoxServer.id == server_id).first()
if not server:
logger.error(f"Proxmox server {server_id} not found")
await websocket.close(code=1008, reason="Proxmox server not found")
return
proxmox_ws = None
bytes_to_proxmox = 0
bytes_from_proxmox = 0
try:
# Шаг 1: Получаем auth ticket через Proxmox API
async with httpx.AsyncClient(verify=server.verify_ssl, timeout=10) as client:
auth_url = f"https://{server.ip_address}:8006/api2/json/access/ticket"
# Используем пароль если есть, иначе API token
if server.password:
auth_data = {
"username": server.username,
"password": server.password
}
response = await client.post(auth_url, data=auth_data)
else:
# Для API token используем другой метод авторизации
logger.error("Terminal requires password authentication, API tokens not supported")
await websocket.close(code=1011, reason="Terminal requires password auth")
# Шаг 1: Auth ticket
async with httpx.AsyncClient(verify=False, timeout=10) as client:
if not server.password:
await websocket.close(code=1011, reason="Terminal requires password authentication")
return
if response.status_code != 200:
logger.error(f"Auth failed: {response.status_code}")
auth_username = server.api_user.split("!")[0] if "!" in server.api_user else server.api_user
auth_resp = await client.post(
f"https://{server.ip_address}:8006/api2/json/access/ticket",
data={"username": auth_username, "password": server.password}
)
if auth_resp.status_code != 200:
logger.error(f"Auth failed: {auth_resp.status_code} {auth_resp.text}")
await websocket.close(code=1011, reason="Authentication failed")
return
auth_result = response.json()
ticket = auth_result["data"]["ticket"]
csrf_token = auth_result["data"]["CSRFPreventionToken"]
logger.info(f"✅ Auth ticket obtained for {server.username}")
# Шаг 2: Получаем termproxy ticket
termproxy_url = f"https://{server.ip_address}:8006/api2/json/nodes/{node}/lxc/{vmid}/termproxy"
headers = {
"CSRFPreventionToken": csrf_token,
"Cookie": f"PVEAuthCookie={ticket}"
}
payload = {
"CSRFPreventionToken": csrf_token
}
response = await client.post(termproxy_url, headers=headers, data=payload)
if response.status_code != 200:
logger.error(f"Termproxy failed: {response.status_code} - {response.text}")
auth_data = auth_resp.json()["data"]
ticket = auth_data["ticket"]
csrf_token = auth_data["CSRFPreventionToken"]
logger.info(f"✅ Auth ticket obtained for {auth_username}")
# Шаг 2: Termproxy
term_resp = await client.post(
f"https://{server.ip_address}:8006/api2/json/nodes/{node}/lxc/{vmid}/termproxy",
headers={"CSRFPreventionToken": csrf_token},
cookies={"PVEAuthCookie": ticket}
)
if term_resp.status_code != 200:
logger.error(f"Termproxy failed: {term_resp.status_code} - {term_resp.text}")
await websocket.close(code=1011, reason="Failed to create terminal session")
return
termproxy_result = response.json()
vncticket = termproxy_result["data"]["ticket"]
port = termproxy_result["data"]["port"]
logger.info(f"✅ Termproxy ticket obtained, port: {port}")
# Шаг 3: Подключаемся к WebSocket
websocket_url = (
f"wss://{server.ip_address}:8006/api2/json/nodes/{node}/lxc/{vmid}/vncwebsocket?"
f"port={port}&vncticket={vncticket}"
term_data = term_resp.json()["data"]
vncticket = term_data["ticket"]
port = term_data["port"]
logger.info(f"✅ Termproxy response: port={port}, ticket_prefix={vncticket[:30]}...")
# Шаг 3: WebSocket к Proxmox — идентично VNC handler
proxmox_ws_url = (
f"wss://{server.ip_address}:8006/api2/json/nodes/{node}/lxc/{vmid}/vncwebsocket"
f"?port={port}&vncticket={quote(vncticket, safe='')}"
)
ssl_context = ssl.create_default_context()
if not server.verify_ssl:
ssl_context.check_hostname = False
ssl_context.verify_mode = ssl.CERT_NONE
logger.info(f"Connecting to Proxmox terminal WebSocket...")
async with websockets.connect(
websocket_url,
ssl_context.check_hostname = False
ssl_context.verify_mode = ssl.CERT_NONE
proxmox_ws = await websockets.connect(
proxmox_ws_url,
ssl=ssl_context,
extra_headers=[("Cookie", f"PVEAuthCookie={ticket}")],
subprotocols=["binary"],
max_size=None,
ping_interval=30,
ping_timeout=10
) as proxmox_ws:
logger.info(f"✅ Connected to Proxmox terminal WebSocket")
async def client_to_proxmox():
"""Передача данных от xterm.js к Proxmox"""
try:
while True:
data = await websocket.receive()
if 'text' in data:
msg = data['text']
# Proxmox ожидает чистые данные без протокола
if msg.startswith('0:'):
# stdin: "0:LENGTH:DATA"
parts = msg.split(':', 2)
if len(parts) == 3:
terminal_data = parts[2]
await proxmox_ws.send(terminal_data)
elif msg.startswith('1:'):
# resize - Proxmox не поддерживает через WebSocket
pass
elif msg == '2':
# ping
pass
elif 'bytes' in data:
await proxmox_ws.send(data['bytes'])
except WebSocketDisconnect:
logger.info(f"Client disconnected from terminal {vmid}")
except Exception as e:
logger.error(f"Error in client_to_proxmox: {e}")
async def proxmox_to_client():
"""Передача данных от Proxmox к xterm.js"""
try:
async for message in proxmox_ws:
if isinstance(message, bytes):
await websocket.send_bytes(message)
else:
await websocket.send_text(message)
except websockets.exceptions.ConnectionClosed:
logger.info(f"Proxmox terminal WebSocket closed for {vmid}")
except Exception as e:
logger.error(f"Error in proxmox_to_client: {e}")
# Запускаем обе задачи параллельно
await asyncio.gather(
client_to_proxmox(),
proxmox_to_client(),
return_exceptions=True
)
ping_interval=None,
close_timeout=5
)
logger.info(f"✅ Connected to Proxmox terminal WebSocket (port={port})")
# CRITICAL: Proxmox termproxy requires auth handshake first.
# Send "USERNAME:VNCTICKET\n" as the very first message.
# Proxmox responds with "OK" (bytes 0x4F 0x4B), optionally followed by initial terminal data.
await proxmox_ws.send(f"{auth_username}:{vncticket}\n")
logger.info(f"✅ Sent termproxy auth: {auth_username}:***")
try:
ok_raw = await asyncio.wait_for(proxmox_ws.recv(), timeout=10.0)
except asyncio.TimeoutError:
logger.error("Proxmox termproxy auth timeout — no OK received")
await websocket.close(code=1011, reason="Terminal auth timeout")
return
ok_bytes = ok_raw if isinstance(ok_raw, bytes) else ok_raw.encode("utf-8")
if not ok_bytes.startswith(b"OK"):
logger.error(f"Proxmox termproxy auth rejected: {ok_bytes[:50]!r}")
await websocket.close(code=1011, reason="Terminal authentication rejected")
return
logger.info("✅ Proxmox termproxy auth OK!")
# Send any terminal data that arrived together with "OK"
if len(ok_bytes) > 2:
await websocket.send_bytes(ok_bytes[2:])
async def client_to_proxmox():
nonlocal bytes_to_proxmox
try:
while True:
message = await websocket.receive()
if message["type"] == "websocket.disconnect":
break
if "bytes" in message:
bytes_to_proxmox += len(message["bytes"])
await proxmox_ws.send(message["bytes"])
elif "text" in message:
bytes_to_proxmox += len(message["text"])
await proxmox_ws.send(message["text"].encode("utf-8"))
except WebSocketDisconnect:
pass
except Exception as e:
logger.debug(f"client_to_proxmox ended: {e}")
async def proxmox_to_client():
nonlocal bytes_from_proxmox
try:
async for message in proxmox_ws:
if isinstance(message, bytes):
bytes_from_proxmox += len(message)
await websocket.send_bytes(message)
else:
bytes_from_proxmox += len(message)
await websocket.send_bytes(message.encode("utf-8"))
except Exception as e:
logger.debug(f"proxmox_to_client ended: {e}")
done, pending = await asyncio.wait(
[
asyncio.create_task(client_to_proxmox()),
asyncio.create_task(proxmox_to_client()),
],
return_when=asyncio.FIRST_COMPLETED
)
for task in pending:
task.cancel()
except Exception as e:
logger.error(f"Terminal WebSocket error: {e}")
import traceback
@@ -2720,9 +2724,21 @@ async def terminal_websocket_fixed(
except Exception:
pass
finally:
logger.info(f"Terminal stats — to Proxmox: {bytes_to_proxmox}B, from Proxmox: {bytes_from_proxmox}B")
if proxmox_ws:
try:
await proxmox_ws.close()
except Exception:
pass
try:
await websocket.close()
except Exception:
pass
logger.info(f"Terminal session closed for container {vmid}")
# ==================== Bulk Operations API ====================
@router.post("/api/bulk-operation")

View File

@@ -1460,12 +1460,17 @@ async function connectTerminal() {
statusEl.style.color = 'var(--success)';
terminalInstance.focus();
// Send initial resize
const dims = fitAddon.proposeDimensions();
if (dims) {
const resizeMsg = `1:${dims.cols}:${dims.rows}:`;
terminalWebSocket.send(resizeMsg);
}
// Send initial resize — fallback to 80x24 if proposeDimensions returns null
// (happens when xterm.css not loaded yet)
const sendResize = () => {
const dims = fitAddon.proposeDimensions();
const cols = dims ? dims.cols : 80;
const rows = dims ? dims.rows : 24;
terminalWebSocket.send(`1:${cols}:${rows}:`);
};
// Send immediately and re-send after layout settles
sendResize();
setTimeout(sendResize, 200);
};
terminalWebSocket.onmessage = (event) => {

View File

@@ -38,8 +38,8 @@ server {
proxy_read_timeout 60s;
}
# WebSocket support (for future notifications)
location /ws {
# WebSocket support — tasks и terminal
location ~ ^/(ws|proxmox/ws)/ {
proxy_pass http://serverpanel-app:8000;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
@@ -49,6 +49,7 @@ server {
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_read_timeout 86400;
proxy_send_timeout 86400;
}
# API rate limiting (stricter)

View File

@@ -66,8 +66,8 @@ server {
proxy_read_timeout 60s;
}
# WebSocket support (for future notifications)
location /ws {
# WebSocket support — tasks и terminal
location ~ ^/(ws|proxmox/ws)/ {
proxy_pass http://serverpanel-app:8000;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
@@ -77,6 +77,7 @@ server {
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_read_timeout 86400;
proxy_send_timeout 86400;
}
# API rate limiting (stricter)