feat: add VNC WebSocket console support for Docker and QEMU nodes

Add VNC console WebSocket endpoints for Docker and QEMU nodes:
- Add /console/vnc WebSocket endpoint to compute API (Docker & QEMU)
- Add /console/vnc WebSocket endpoint to controller API
- Implement start_vnc_websocket_console() in BaseNode
- Forward VNC WebSocket traffic between controller and compute layers

The implementation provides bidirectional WebSocket to TCP forwarding
for VNC protocol (RFB) connections, allowing browser-based VNC console
access to containers and VMs.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
YueGuobin 2026-03-16 16:40:00 +08:00
parent 09aa12cf94
commit ba92b405da
4 changed files with 176 additions and 1 deletions

View File

@ -384,6 +384,21 @@ async def console_ws(
await node.start_websocket_console(websocket) await node.start_websocket_console(websocket)
@router.websocket(
"/{node_id}/console/vnc"
)
async def vnc_console_ws(
websocket: Union[None, WebSocket] = Depends(ws_compute_authentication),
node: DockerVM = Depends(dep_node)
) -> None:
"""
VNC Console WebSocket.
"""
if websocket:
await node.start_vnc_websocket_console(websocket)
@router.post( @router.post(
"/{node_id}/console/reset", "/{node_id}/console/reset",
status_code=status.HTTP_204_NO_CONTENT, status_code=status.HTTP_204_NO_CONTENT,

View File

@ -27,6 +27,7 @@ from typing import Union
from uuid import UUID from uuid import UUID
from gns3server import schemas from gns3server import schemas
from gns3server.compute import qemu
from gns3server.compute.qemu import Qemu from gns3server.compute.qemu import Qemu
from gns3server.compute.qemu.qemu_vm import QemuVM from gns3server.compute.qemu.qemu_vm import QemuVM
@ -414,6 +415,21 @@ async def console_ws(
await node.start_websocket_console(websocket) await node.start_websocket_console(websocket)
@router.websocket(
"/{node_id}/console/vnc"
)
async def vnc_console_ws(
websocket: Union[None, WebSocket] = Depends(ws_compute_authentication),
node: QemuVM = Depends(dep_node)
) -> None:
"""
VNC Console WebSocket.
"""
if websocket:
await node.start_vnc_websocket_console(websocket)
@router.post( @router.post(
"/{node_id}/console/reset", "/{node_id}/console/reset",
status_code=status.HTTP_204_NO_CONTENT, status_code=status.HTTP_204_NO_CONTENT,

View File

@ -646,6 +646,80 @@ async def ws_console(
except aiohttp.ClientError as e: except aiohttp.ClientError as e:
log.error(f"Client error received when forwarding to compute console WebSocket: {e}") log.error(f"Client error received when forwarding to compute console WebSocket: {e}")
@router.websocket("/{node_id}/console/vnc")
async def vnc_console(
websocket: WebSocket,
current_user: schemas.User = Depends(has_privilege_on_websocket("Node.Console")),
node: Node = Depends(dep_node)
) -> None:
"""
VNC WebSocket console.
Required privilege: Node.Console
"""
if current_user is None:
return
compute = node.compute
log.info(
f"New client {websocket.client.host}:{websocket.client.port} has connected to controller VNC console WebSocket"
)
compute_host = compute.host
try:
# handle IPv6 address
ip = ipaddress.ip_address(compute_host)
if isinstance(ip, ipaddress.IPv6Address):
compute_host = '[' + compute_host + ']'
except ValueError:
pass
vnc_console_compute_url = (
f"{websocket.url.scheme}://{compute_host}:{compute.port}/v3/compute/projects/"
f"{node.project.id}/{node.node_type}/nodes/{node.id}/console/vnc"
)
async def vnc_receive(vnc_console_compute):
"""
Receive binary WebSocket data from client and forward to compute VNC console WebSocket.
"""
try:
while True:
data = await websocket.receive_bytes()
if data:
await vnc_console_compute.send_bytes(data)
except WebSocketDisconnect:
await vnc_console_compute.close()
log.info(
f"Client {websocket.client.host}:{websocket.client.port} has disconnected from controller"
f" VNC console WebSocket"
)
try:
# receive binary data from compute VNC console WebSocket and forward to client
log.info(f"Forwarding VNC console WebSocket to '{vnc_console_compute_url}'")
server_config = Config.instance().settings.Server
user = server_config.compute_username
password = server_config.compute_password
if not user:
raise ControllerForbiddenError("Compute username is not set")
user = user.strip()
if user and password:
auth = aiohttp.BasicAuth(user, password.get_secret_value(), "utf-8")
else:
auth = aiohttp.BasicAuth(user, "")
ssl_context = Controller.instance().ssl_context()
async with HTTPClient.get_client().ws_connect(vnc_console_compute_url, auth=auth, ssl_context=ssl_context) as ws:
asyncio.ensure_future(vnc_receive(ws))
async for msg in ws:
if msg.type == aiohttp.WSMsgType.BINARY:
await websocket.send_bytes(msg.data)
elif msg.type == aiohttp.WSMsgType.ERROR:
break
except aiohttp.ClientError as e:
log.error(f"Client error received when forwarding to compute VNC console WebSocket: {e}")
@router.post( @router.post(
"/console/reset", "/console/reset",

View File

@ -484,7 +484,7 @@ class BaseNode:
""" """
Connect to console using Websocket. Connect to console using Websocket.
:param ws: Websocket object :param websocket: FastAPI WebSocket object
""" """
log.info( log.info(
@ -541,6 +541,76 @@ class BaseNode:
for task in pending: for task in pending:
task.cancel() task.cancel()
async def start_vnc_websocket_console(self, websocket):
"""
Connect to VNC console using WebSocket.
:param websocket: FastAPI WebSocket object
"""
log.info(
f"New client {websocket.client.host}:{websocket.client.port} has connected to compute "
f"VNC console WebSocket"
)
if self.status != "started":
await websocket.close(code=1000)
raise NodeError(f"Node {self.name} is not started")
if self._console_type != "vnc":
await websocket.close(code=1000)
raise NodeError(f"Node {self.name} console type is not vnc")
try:
vnc_reader, vnc_writer = await asyncio.open_connection(
self._manager.port_manager.console_host,
self.console # VNC port
)
log.info(f"Connected to VNC server {self._manager.port_manager.console_host}:{self.console}")
except ConnectionError as e:
await websocket.close(code=1000)
raise NodeError(f"Cannot connect to node {self.name} VNC server: {e}")
async def ws_forward(vnc_writer):
# Browser → VNC: Forward binary WebSocket data to VNC server
try:
while True:
data = await websocket.receive_bytes()
if data:
vnc_writer.write(data)
await vnc_writer.drain()
except WebSocketDisconnect:
log.info(
f"Client {websocket.client.host}:{websocket.client.port} has disconnected from compute "
f"VNC console WebSocket"
)
async def vnc_forward(vnc_reader):
# VNC → Browser: Forward VNC frames to WebSocket
try:
while not vnc_reader.at_eof():
data = await vnc_reader.read(65536) # Larger buffer for VNC frames
if data:
await websocket.send_bytes(data)
except Exception as e:
log.warning(f"Exception while forwarding VNC data to WebSocket: {e}")
# Keep forwarding WebSocket data in both directions
if sys.version_info >= (3, 11, 0):
# Starting with Python 3.11, passing coroutine objects to wait() directly is forbidden.
aws = [asyncio.create_task(ws_forward(vnc_writer)), asyncio.create_task(vnc_forward(vnc_reader))]
else:
aws = [ws_forward(vnc_writer), vnc_forward(vnc_reader)]
done, pending = await asyncio.wait(aws, return_when=asyncio.FIRST_COMPLETED)
for task in done:
if task.exception():
log.warning(f"Exception while forwarding WebSocket data to VNC server: {task.exception()}")
for task in pending:
task.cancel()
vnc_writer.close()
await vnc_writer.wait_closed()
@property @property
def aux(self): def aux(self):
""" """