diff --git a/gns3server/compute/base_node.py b/gns3server/compute/base_node.py index 3fae6e0f..452a83d9 100644 --- a/gns3server/compute/base_node.py +++ b/gns3server/compute/base_node.py @@ -27,6 +27,7 @@ import psutil import platform import re +from aiohttp.web import WebSocketResponse from gns3server.utils.interfaces import interfaces from ..compute.port_manager import PortManager from ..utils.asyncio import wait_run_in_executor, locking @@ -339,8 +340,8 @@ class BaseNode: async def start_wrap_console(self): """ - Start a telnet proxy for the console allowing multiple client - connected at the same time + Start a telnet proxy for the console allowing multiple telnet clients + to be connected at the same time """ if not self._wrap_console or self._console_type != "telnet": @@ -369,6 +370,62 @@ class BaseNode: self._wrapper_telnet_server.close() await self._wrapper_telnet_server.wait_closed() + async def start_websocket_console(self, request): + """ + Connect to console using Websocket. + + :param ws: Websocket object + """ + + if self.status != "started": + raise NodeError("Node {} is not started".format(self.name)) + + if self._console_type != "telnet": + raise NodeError("Node {} console type is not telnet".format(self.name)) + + try: + (telnet_reader, telnet_writer) = await asyncio.open_connection(self._manager.port_manager.console_host, self.console) + except ConnectionError as e: + raise NodeError("Cannot connect to node {} telnet server: {}".format(self.name, e)) + + log.info("Connected to Telnet server") + + ws = WebSocketResponse() + await ws.prepare(request) + request.app['websockets'].add(ws) + + log.info("New client has connected to console WebSocket") + + async def ws_forward(telnet_writer): + + async for msg in ws: + if msg.type == aiohttp.WSMsgType.TEXT: + telnet_writer.write(msg.data.encode()) + await telnet_writer.drain() + elif msg.type == aiohttp.WSMsgType.BINARY: + await telnet_writer.write(msg.data) + await telnet_writer.drain() + elif msg.type == aiohttp.WSMsgType.ERROR: + log.debug("Websocket connection closed with exception {}".format(ws.exception())) + + async def telnet_forward(telnet_reader): + + while not ws.closed and not telnet_reader.at_eof(): + data = await telnet_reader.read(1024) + if data: + await ws.send_bytes(data) + + try: + # keep forwarding websocket data in both direction + await asyncio.wait([ws_forward(telnet_writer), telnet_forward(telnet_reader)], return_when=asyncio.FIRST_COMPLETED) + finally: + log.info("Client has disconnected from console WebSocket") + if not ws.closed: + await ws.close() + request.app['websockets'].discard(ws) + + return ws + @property def allocate_aux(self): """ diff --git a/gns3server/handlers/api/compute/docker_handler.py b/gns3server/handlers/api/compute/docker_handler.py index 83a5dd0d..e510a9f2 100644 --- a/gns3server/handlers/api/compute/docker_handler.py +++ b/gns3server/handlers/api/compute/docker_handler.py @@ -412,3 +412,16 @@ class DockerHandler: docker_manager = Docker.instance() images = await docker_manager.list_images() response.json(images) + + @Route.get( + r"/projects/{project_id}/docker/nodes/{node_id}/console/ws", + description="WebSocket for console", + parameters={ + "project_id": "Project UUID", + "node_id": "Node UUID", + }) + async def console_ws(request, response): + + docker_manager = Docker.instance() + container = docker_manager.get_node(request.match_info["node_id"], project_id=request.match_info["project_id"]) + return await container.start_websocket_console(request) diff --git a/gns3server/handlers/api/compute/dynamips_vm_handler.py b/gns3server/handlers/api/compute/dynamips_vm_handler.py index 88bb1a9d..ef64e482 100644 --- a/gns3server/handlers/api/compute/dynamips_vm_handler.py +++ b/gns3server/handlers/api/compute/dynamips_vm_handler.py @@ -513,3 +513,15 @@ class DynamipsVMHandler: response.set_status(201) response.json(new_node) + @Route.get( + r"/projects/{project_id}/dynamips/nodes/{node_id}/console/ws", + description="WebSocket for console", + parameters={ + "project_id": "Project UUID", + "node_id": "Node UUID", + }) + async def console_ws(request, response): + + dynamips_manager = Dynamips.instance() + vm = dynamips_manager.get_node(request.match_info["node_id"], project_id=request.match_info["project_id"]) + return await vm.start_websocket_console(request) diff --git a/gns3server/handlers/api/compute/iou_handler.py b/gns3server/handlers/api/compute/iou_handler.py index 5238f90c..9a6649fc 100644 --- a/gns3server/handlers/api/compute/iou_handler.py +++ b/gns3server/handlers/api/compute/iou_handler.py @@ -453,3 +453,16 @@ class IOUHandler: raise aiohttp.web.HTTPForbidden() await response.stream_file(image_path) + + @Route.get( + r"/projects/{project_id}/iou/nodes/{node_id}/console/ws", + description="WebSocket for console", + parameters={ + "project_id": "Project UUID", + "node_id": "Node UUID", + }) + async def console_ws(request, response): + + iou_manager = IOU.instance() + vm = iou_manager.get_node(request.match_info["node_id"], project_id=request.match_info["project_id"]) + return await vm.start_websocket_console(request) diff --git a/gns3server/handlers/api/compute/qemu_handler.py b/gns3server/handlers/api/compute/qemu_handler.py index 0c3779cc..7b2ceb24 100644 --- a/gns3server/handlers/api/compute/qemu_handler.py +++ b/gns3server/handlers/api/compute/qemu_handler.py @@ -580,3 +580,16 @@ class QEMUHandler: raise aiohttp.web.HTTPForbidden() await response.stream_file(image_path) + + @Route.get( + r"/projects/{project_id}/qemu/nodes/{node_id}/console/ws", + description="WebSocket for console", + parameters={ + "project_id": "Project UUID", + "node_id": "Node UUID", + }) + async def console_ws(request, response): + + qemu_manager = Qemu.instance() + vm = qemu_manager.get_node(request.match_info["node_id"], project_id=request.match_info["project_id"]) + return await vm.start_websocket_console(request) diff --git a/gns3server/handlers/api/compute/virtualbox_handler.py b/gns3server/handlers/api/compute/virtualbox_handler.py index 366778e4..373d3fbd 100644 --- a/gns3server/handlers/api/compute/virtualbox_handler.py +++ b/gns3server/handlers/api/compute/virtualbox_handler.py @@ -424,3 +424,16 @@ class VirtualBoxHandler: vbox_manager = VirtualBox.instance() vms = await vbox_manager.list_vms() response.json(vms) + + @Route.get( + r"/projects/{project_id}/virtualbox/nodes/{node_id}/console/ws", + description="WebSocket for console", + parameters={ + "project_id": "Project UUID", + "node_id": "Node UUID", + }) + async def console_ws(request, response): + + virtualbox_manager = VirtualBox.instance() + vm = virtualbox_manager.get_node(request.match_info["node_id"], project_id=request.match_info["project_id"]) + return await vm.start_websocket_console(request) diff --git a/gns3server/handlers/api/compute/vmware_handler.py b/gns3server/handlers/api/compute/vmware_handler.py index 9c596deb..5b92f62f 100644 --- a/gns3server/handlers/api/compute/vmware_handler.py +++ b/gns3server/handlers/api/compute/vmware_handler.py @@ -409,3 +409,16 @@ class VMwareHandler: vmware_manager = VMware.instance() vms = await vmware_manager.list_vms() response.json(vms) + + @Route.get( + r"/projects/{project_id}/vmware/nodes/{node_id}/console/ws", + description="WebSocket for console", + parameters={ + "project_id": "Project UUID", + "node_id": "Node UUID", + }) + async def console_ws(request, response): + + vmware_manager = VMware.instance() + vm = vmware_manager.get_node(request.match_info["node_id"], project_id=request.match_info["project_id"]) + return await vm.start_websocket_console(request) diff --git a/gns3server/handlers/api/compute/vpcs_handler.py b/gns3server/handlers/api/compute/vpcs_handler.py index 51a3ca7e..63075c8a 100644 --- a/gns3server/handlers/api/compute/vpcs_handler.py +++ b/gns3server/handlers/api/compute/vpcs_handler.py @@ -362,3 +362,16 @@ class VPCSHandler: port_number = int(request.match_info["port_number"]) nio = vm.get_nio(port_number) await vpcs_manager.stream_pcap_file(nio, vm.project.id, request, response) + + @Route.get( + r"/projects/{project_id}/vpcs/nodes/{node_id}/console/ws", + description="WebSocket for console", + parameters={ + "project_id": "Project UUID", + "node_id": "Node UUID", + }) + async def console_ws(request, response): + + vpcs_manager = VPCS.instance() + vm = vpcs_manager.get_node(request.match_info["node_id"], project_id=request.match_info["project_id"]) + return await vm.start_websocket_console(request) diff --git a/gns3server/handlers/api/controller/node_handler.py b/gns3server/handlers/api/controller/node_handler.py index db3ae4a6..51d68e7e 100644 --- a/gns3server/handlers/api/controller/node_handler.py +++ b/gns3server/handlers/api/controller/node_handler.py @@ -16,6 +16,7 @@ # along with this program. If not, see . import aiohttp +import asyncio from gns3server.web.route import Route from gns3server.controller import Controller @@ -453,3 +454,57 @@ class NodeHandler: data = await request.content.read() #FIXME: are we handling timeout or large files correctly? await node.compute.http_query("POST", "/projects/{project_id}/files{path}".format(project_id=project.id, path=path), data=data, timeout=None, raw=True) response.set_status(201) + + @Route.get( + r"/projects/{project_id}/nodes/{node_id}/console/ws", + parameters={ + "project_id": "Project UUID", + "node_id": "Node UUID" + }, + description="Connect to WebSocket console", + status_codes={ + 200: "File returned", + 403: "Permission denied", + 404: "The file doesn't exist" + }) + async def ws_console(request, response): + + project = await Controller.instance().get_loaded_project(request.match_info["project_id"]) + node = project.get_node(request.match_info["node_id"]) + compute = node.compute + ws = aiohttp.web.WebSocketResponse() + await ws.prepare(request) + request.app['websockets'].add(ws) + + ws_console_compute_url = "ws://{compute_host}:{compute_port}/v2/compute/projects/{project_id}/{node_type}/nodes/{node_id}/console/ws".format(compute_host=compute.host, + compute_port=compute.port, + project_id=project.id, + node_type=node.node_type, + node_id=node.id) + + async def ws_forward(ws_client): + async for msg in ws: + if msg.type == aiohttp.WSMsgType.TEXT: + await ws_client.send_str(msg.data) + elif msg.type == aiohttp.WSMsgType.BINARY: + await ws_client.send_bytes(msg.data) + elif msg.type == aiohttp.WSMsgType.ERROR: + break + + try: + async with aiohttp.ClientSession(connector=aiohttp.TCPConnector(limit=None, force_close=True)) as session: + async with session.ws_connect(ws_console_compute_url) as ws_client: + asyncio.ensure_future(ws_forward(ws_client)) + async for msg in ws_client: + if msg.type == aiohttp.WSMsgType.TEXT: + await ws.send_str(msg.data) + elif msg.type == aiohttp.WSMsgType.BINARY: + await ws.send_bytes(msg.data) + elif msg.type == aiohttp.WSMsgType.ERROR: + break + finally: + if not ws.closed: + await ws.close() + request.app['websockets'].discard(ws) + + return ws diff --git a/gns3server/web/route.py b/gns3server/web/route.py index 8efd1bf6..59fb5f8e 100644 --- a/gns3server/web/route.py +++ b/gns3server/web/route.py @@ -106,7 +106,8 @@ class Route(object): :returns: Response if you need to auth the user otherwise None """ - if not server_config.getboolean("auth", False): + # FIXME: ugly exception to not require authentication for websocket consoles + if not server_config.getboolean("auth", False) or request.path.endswith("console/ws"): return None user = server_config.get("user", "").strip() @@ -253,10 +254,11 @@ class Route(object): """ To avoid strange effect we prevent concurrency between the same instance of the node - (excepting when streaming a PCAP file). + (excepting when streaming a PCAP file and WebSocket consoles). """ - if "node_id" in request.match_info and not "pcap" in request.path: + #FIXME: ugly exceptions for capture and websocket console + if "node_id" in request.match_info and not "pcap" in request.path and not request.path.endswith("console/ws"): node_id = request.match_info.get("node_id") if "compute" in request.path: