From ba92b405dadea4f271a38eeb1f0f97b024ed2feb Mon Sep 17 00:00:00 2001 From: YueGuobin Date: Mon, 16 Mar 2026 16:40:00 +0800 Subject: [PATCH] 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 --- gns3server/api/routes/compute/docker_nodes.py | 15 ++++ gns3server/api/routes/compute/qemu_nodes.py | 16 ++++ gns3server/api/routes/controller/nodes.py | 74 +++++++++++++++++++ gns3server/compute/base_node.py | 72 +++++++++++++++++- 4 files changed, 176 insertions(+), 1 deletion(-) diff --git a/gns3server/api/routes/compute/docker_nodes.py b/gns3server/api/routes/compute/docker_nodes.py index 978eff344..1d9e98530 100644 --- a/gns3server/api/routes/compute/docker_nodes.py +++ b/gns3server/api/routes/compute/docker_nodes.py @@ -384,6 +384,21 @@ async def console_ws( 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( "/{node_id}/console/reset", status_code=status.HTTP_204_NO_CONTENT, diff --git a/gns3server/api/routes/compute/qemu_nodes.py b/gns3server/api/routes/compute/qemu_nodes.py index 4b9da897f..623ae8dae 100644 --- a/gns3server/api/routes/compute/qemu_nodes.py +++ b/gns3server/api/routes/compute/qemu_nodes.py @@ -27,6 +27,7 @@ from typing import Union from uuid import UUID from gns3server import schemas +from gns3server.compute import qemu from gns3server.compute.qemu import Qemu from gns3server.compute.qemu.qemu_vm import QemuVM @@ -414,6 +415,21 @@ async def console_ws( 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( "/{node_id}/console/reset", status_code=status.HTTP_204_NO_CONTENT, diff --git a/gns3server/api/routes/controller/nodes.py b/gns3server/api/routes/controller/nodes.py index 62e969c41..aa4eddc0a 100644 --- a/gns3server/api/routes/controller/nodes.py +++ b/gns3server/api/routes/controller/nodes.py @@ -646,6 +646,80 @@ async def ws_console( except aiohttp.ClientError as 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( "/console/reset", diff --git a/gns3server/compute/base_node.py b/gns3server/compute/base_node.py index 4d56624a8..e09111de4 100644 --- a/gns3server/compute/base_node.py +++ b/gns3server/compute/base_node.py @@ -484,7 +484,7 @@ class BaseNode: """ Connect to console using Websocket. - :param ws: Websocket object + :param websocket: FastAPI WebSocket object """ log.info( @@ -541,6 +541,76 @@ class BaseNode: for task in pending: 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 def aux(self): """