feat(compute): add SSH console type support

This commit is contained in:
Cristi committed 2026-04-30 15:52:47 +03:00
1 parent f0ef65b8e8
commit d64eca0418
18 files changed
+421 -74

No files matched your search

+102 -39
View File
@@ -23,12 +23,14 @@ import tempfile
import psutil
import platform
import re
import asyncssh
from fastapi import WebSocketDisconnect
from gns3server.utils.interfaces import interfaces
from gns3server.compute.compute_error import ComputeError
from ..compute.port_manager import PortManager
from ..utils.asyncio import wait_run_in_executor, locking
from ..utils.asyncio.ssh_server import AsyncioSSHServer
from ..utils.asyncio.telnet_server import AsyncioTelnetServer
from gns3server.compute.ubridge.hypervisor import Hypervisor
from gns3server.compute.ubridge.ubridge_error import UbridgeError
@@ -54,8 +56,8 @@ class BaseNode:
:param aux: auxiliary console TCP port
:param aux_type: auxiliary console type
:param linked_clone: The node base image is duplicate/overlay (Each node data are independent)
:param wrap_console: The console is wrapped using AsyncioTelnetServer
:param wrap_aux: The auxiliary console is wrapped using AsyncioTelnetServer
:param wrap_console: The console is wrapped using a proxy transport server
:param wrap_aux: The auxiliary console is wrapped using a proxy transport server
"""
def __init__(
@@ -409,10 +411,10 @@ class BaseNode:
return vnc_console_start_port_range, vnc_console_end_port_range
async def _wrap_telnet_proxy(self, internal_port, external_port):
async def _wrap_console_proxy(self, internal_port, external_port, console_type):
"""
Start a telnet proxy for the console allowing multiple telnet clients
to be connected at the same time
Start a console proxy allowing multiple external clients to be
connected at the same time.
"""
remaining_trial = 60
@@ -420,7 +422,7 @@ class BaseNode:
try:
(self._wrap_console_reader, self._wrap_console_writer) = await asyncio.open_connection(
host="127.0.0.1",
port=self._internal_console_port
port=internal_port
)
break
except (OSError, ConnectionRefusedError) as e:
@@ -428,40 +430,46 @@ class BaseNode:
raise e
await asyncio.sleep(0.1)
remaining_trial -= 1
await AsyncioTelnetServer.write_client_intro(self._wrap_console_writer, echo=True)
server = AsyncioTelnetServer(
reader=self._wrap_console_reader,
writer=self._wrap_console_writer,
binary=True,
echo=True
)
if console_type == "telnet":
await AsyncioTelnetServer.write_client_intro(self._wrap_console_writer, echo=True)
server = AsyncioTelnetServer(
reader=self._wrap_console_reader,
writer=self._wrap_console_writer,
binary=True,
echo=True
)
elif console_type == "ssh":
server = AsyncioSSHServer(reader=self._wrap_console_reader, writer=self._wrap_console_writer)
else:
raise NodeError(f"Console wrapper does not support type {console_type}")
# warning: this will raise OSError exception if there is a problem...
telnet_server = await server.start(self._manager.port_manager.console_host, external_port)
self._wrapper_telnet_servers.append(telnet_server)
proxy_server = await server.start(self._manager.port_manager.console_host, external_port)
self._wrapper_telnet_servers.append(proxy_server)
async def start_wrap_console(self):
"""
Start a Telnet proxy servers for the console and auxiliary console allowing multiple telnet clients
Start console proxy servers for the console and auxiliary console allowing multiple clients
to be connected at the same time
"""
if self._wrap_console and self._console_type == "telnet":
await self._wrap_telnet_proxy(self._internal_console_port, self.console)
if self._wrap_console and self._console_type in ("telnet", "ssh"):
await self._wrap_console_proxy(self._internal_console_port, self.console, self._console_type)
log.info(
f"New Telnet proxy server for console started "
f"New {self._console_type.upper()} proxy server for console started "
f"(internal port = {self._internal_console_port}, external port = {self.console})"
)
if self._wrap_aux and self._aux_type == "telnet":
await self._wrap_telnet_proxy(self._internal_aux_port, self.aux)
if self._wrap_aux and self._aux_type in ("telnet", "ssh"):
await self._wrap_console_proxy(self._internal_aux_port, self.aux, self._aux_type)
log.info(
f"New Telnet proxy server for auxiliary console started "
f"New {self._aux_type.upper()} proxy server for auxiliary console started "
f"(internal port = {self._internal_aux_port}, external port = {self.aux})"
)
async def stop_wrap_console(self):
"""
Stops the telnet proxy servers.
Stops the console proxy servers.
"""
if self._wrap_console_writer:
@@ -474,7 +482,7 @@ class BaseNode:
async def reset_wrap_console(self):
"""
Reset the wrap console (restarts the Telnet proxy)
Reset the wrap console (restarts the console proxy)
"""
await self.stop_wrap_console()
@@ -493,27 +501,66 @@ class BaseNode:
)
if self.status != "started":
raise NodeError(f"Node {self.name} is not started")
await websocket.close(code=1000)
log.warning(f"Cannot open console WebSocket: node {self.name} is not started")
return
if self._console_type != "telnet":
raise NodeError(f"Node {self.name} console type is not telnet")
if self._console_type not in ("telnet", "ssh"):
await websocket.close(code=1000)
log.warning(
f"Cannot open console WebSocket: node {self.name} console type '{self._console_type}' "
f"is not supported"
)
return
telnet_reader = None
telnet_writer = None
ssh_connection = None
ssh_process = None
try:
host = self._manager.port_manager.console_host
port = self.console
(telnet_reader, telnet_writer) = await asyncio.open_connection(host, port)
log.info(f"Connected to local Telnet server {host}:{port}")
except ConnectionError as e:
raise NodeError(f"Cannot connect to node {self.name} telnet server: {e}")
if self._console_type == "ssh":
# For SSH consoles (wrapped or not), connect to the external SSH port via an
# SSH client. The AsyncioSSHServer on that port handles multi-client broadcasting
# to/from the node's process streams. Connecting directly to _internal_console_port
# would conflict with the exclusive connection already held by the SSH proxy.
port = self.console
ssh_connection = await asyncssh.connect(
host,
port=port,
username="gns3",
known_hosts=None,
encoding=None,
)
ssh_process = await ssh_connection.create_process(encoding=None, term_type="xterm")
telnet_reader = ssh_process.stdout
telnet_writer = ssh_process.stdin
else:
port = self.console
(telnet_reader, telnet_writer) = await asyncio.open_connection(host, port)
log.info(f"Connected to local console stream {host}:{port} (console type={self._console_type})")
except (ConnectionError, OSError, asyncssh.Error) as e:
await websocket.close(code=1000)
log.warning(f"Cannot connect to node {self.name} console server: {e}")
return
async def ws_forward(telnet_writer):
try:
while True:
data = await websocket.receive_text()
if data:
telnet_writer.write(data.encode())
await telnet_writer.drain()
msg = await websocket.receive()
if msg["type"] == "websocket.disconnect":
break
if "text" in msg and msg["text"]:
data = msg["text"].encode()
elif "bytes" in msg and msg["bytes"]:
data = msg["bytes"]
else:
continue
telnet_writer.write(data)
await telnet_writer.drain()
except WebSocketDisconnect:
log.info(
f"Client {websocket.client.host}:{websocket.client.port} has disconnected from compute"
@@ -541,6 +588,17 @@ class BaseNode:
for task in pending:
task.cancel()
if ssh_connection:
if ssh_process:
ssh_process.close()
ssh_connection.close()
await ssh_connection.wait_closed()
if telnet_writer and hasattr(telnet_writer, "close"):
telnet_writer.close()
if hasattr(telnet_writer, "wait_closed"):
await telnet_writer.wait_closed()
async def start_vnc_websocket_console(self, websocket):
"""
Connect to VNC console using WebSocket.
@@ -555,10 +613,14 @@ class BaseNode:
if self.status != "started":
await websocket.close(code=1000)
raise NodeError(f"Node {self.name} is not started")
log.warning(f"Cannot open VNC WebSocket: node {self.name} is not started")
return
if self._console_type != "vnc":
await websocket.close(code=1000)
raise NodeError(f"Node {self.name} console type is not vnc")
log.warning(
f"Cannot open VNC WebSocket: node {self.name} console type '{self._console_type}' is not vnc"
)
return
try:
vnc_reader, vnc_writer = await asyncio.open_connection(
@@ -568,7 +630,8 @@ class BaseNode:
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}")
log.warning(f"Cannot connect to node {self.name} VNC server: {e}")
return
async def ws_forward(vnc_writer):
# Browser → VNC: Forward binary WebSocket data to VNC server
+27 -13
View File
@@ -28,6 +28,7 @@ import subprocess
import os
import re
from gns3server.utils.asyncio.ssh_server import AsyncioSSHServer
from gns3server.utils.asyncio.telnet_server import AsyncioTelnetServer
from gns3server.utils.asyncio.raw_command_server import AsyncioRawCommandServer
from gns3server.utils.asyncio import wait_for_file_creation
@@ -667,7 +668,7 @@ class DockerVM(BaseNode):
log.error(line)
raise DockerError(logdata)
if self.console_type == "telnet":
if self.console_type in ("telnet", "ssh"):
await self._start_console()
elif self.console_type == "http" or self.console_type == "https":
await self._start_http()
@@ -702,14 +703,19 @@ class DockerVM(BaseNode):
)
except OSError as e:
raise DockerError(f"Could not start auxiliary console process: {e}")
server = AsyncioTelnetServer(reader=process.stdout, writer=process.stdin, binary=True, echo=True)
if self.aux_type == "telnet":
server = AsyncioTelnetServer(reader=process.stdout, writer=process.stdin, binary=True, echo=True)
transport = "Telnet"
else:
server = AsyncioSSHServer(reader=process.stdout, writer=process.stdin)
transport = "SSH"
try:
self._telnet_servers.append(await server.start(self._manager.port_manager.console_host, self.aux))
except OSError as e:
raise DockerError(
f"Could not start Telnet server on socket {self._manager.port_manager.console_host}:{self.aux}: {e}"
f"Could not start {transport} server on socket {self._manager.port_manager.console_host}:{self.aux}: {e}"
)
log.debug(f"Docker container '{self.name}' started listen for auxiliary telnet on {self.aux}")
log.debug(f"Docker container '{self.name}' started listen for auxiliary {self.aux_type} on {self.aux}")
async def _fix_permissions(self):
"""
@@ -872,7 +878,7 @@ class DockerVM(BaseNode):
async def _start_console(self):
"""
Starts streaming the console via telnet
Starts streaming the console via telnet or ssh
"""
class InputStream:
@@ -889,18 +895,23 @@ class DockerVM(BaseNode):
output_stream = asyncio.StreamReader()
input_stream = InputStream()
telnet = AsyncioTelnetServer(
reader=output_stream,
writer=input_stream,
echo=True,
naws=True,
window_size_changed_callback=self._window_size_changed_callback,
)
if self.console_type == "telnet":
telnet = AsyncioTelnetServer(
reader=output_stream,
writer=input_stream,
echo=True,
naws=True,
window_size_changed_callback=self._window_size_changed_callback,
)
transport = "Telnet"
else:
telnet = AsyncioSSHServer(reader=output_stream, writer=input_stream)
transport = "SSH"
try:
self._telnet_servers.append(await telnet.start(self._manager.port_manager.console_host, self.console))
except OSError as e:
raise DockerError(
f"Could not start Telnet server on socket {self._manager.port_manager.console_host}:{self.console}: {e}"
f"Could not start {transport} server on socket {self._manager.port_manager.console_host}:{self.console}: {e}"
)
self._console_websocket = await self.manager.websocket_query(
@@ -936,6 +947,9 @@ class DockerVM(BaseNode):
Reset the console.
"""
if self.console_type not in ("telnet", "ssh"):
return
if self._console_websocket:
await self._console_websocket.close()
await self._clean_servers()
+43 -6
View File
@@ -80,7 +80,9 @@ class Router(BaseNode):
raise DynamipsError(f"{name} is an invalid name to create a Dynamips node")
super().__init__(
name, node_id, project, manager, console=console, console_type=console_type, aux=aux, aux_type=aux_type
name, node_id, project, manager, console=console, console_type=console_type, aux=aux, aux_type=aux_type,
wrap_console=(console_type == "ssh"),
wrap_aux=(aux_type == "ssh"),
)
self._working_directory = os.path.join(
@@ -248,7 +250,10 @@ class Router(BaseNode):
)
if self._console is not None:
await self._hypervisor.send(f'vm set_con_tcp_port "{self._name}" {self._console}')
# For SSH console, tell Dynamips to listen on the internal port so that
# the AsyncioSSHServer proxy can wrap it on the external console port.
con_port = self._internal_console_port if self._wrap_console and self._internal_console_port else self._console
await self._hypervisor.send(f'vm set_con_tcp_port "{self._name}" {con_port}')
if self.aux is not None:
await self._hypervisor.send(f'vm set_aux_tcp_port "{self._name}" {self.aux}')
@@ -327,6 +332,12 @@ class Router(BaseNode):
self._memory_watcher = FileWatcher(self._memory_files(), self._memory_changed, strategy="hash", delay=30)
monitor_process(self._hypervisor.process, self._termination_callback)
if self._console_type == "ssh":
try:
await self.start_wrap_console()
except OSError as e:
raise DynamipsError(f"Could not start SSH Dynamips console {e}")
async def _termination_callback(self, returncode):
"""
Called when the process has stopped.
@@ -362,6 +373,7 @@ class Router(BaseNode):
self._memory_watcher.close()
self._memory_watcher = None
await self.save_configs()
await self.stop_wrap_console()
async def reload(self):
"""
@@ -1017,8 +1029,21 @@ class Router(BaseNode):
self.console_type = console_type
if self._console and console_type == "telnet":
await self._hypervisor.send(f'vm set_con_tcp_port "{self._name}" {self._console}')
if self._console:
if console_type == "ssh":
# Switching to SSH: ensure an internal port is allocated and redirect Dynamips to it.
self._wrap_console = True
if self._internal_console_port is None:
self._internal_console_port = self._manager.port_manager.get_free_tcp_port(self._project)
await self._hypervisor.send(f'vm set_con_tcp_port "{self._name}" {self._internal_console_port}')
elif console_type == "telnet":
# Switching to telnet: stop SSH wrapper if running and redirect Dynamips to external port.
await self.stop_wrap_console()
self._wrap_console = False
if self._internal_console_port is not None:
self._manager.port_manager.release_tcp_port(self._internal_console_port, self._project)
self._internal_console_port = None
await self._hypervisor.send(f'vm set_con_tcp_port "{self._name}" {self._console}')
async def set_aux(self, aux):
"""
@@ -1047,8 +1072,20 @@ class Router(BaseNode):
self.aux_type = aux_type
if self._aux and aux_type == "telnet":
await self._hypervisor.send(f'vm set_aux_tcp_port "{self._name}" {self._aux}')
if self._aux:
if aux_type == "ssh":
# Switching to SSH: ensure an internal aux port is allocated and redirect Dynamips to it.
self._wrap_aux = True
if self._internal_aux_port is None:
self._internal_aux_port = self._manager.port_manager.get_free_tcp_port(self._project)
await self._hypervisor.send(f'vm set_aux_tcp_port "{self._name}" {self._internal_aux_port}')
elif aux_type == "telnet":
# Switching to telnet: clear SSH wrapper state and redirect Dynamips to external port.
self._wrap_aux = False
if self._internal_aux_port is not None:
self._manager.port_manager.release_tcp_port(self._internal_aux_port, self._project)
self._internal_aux_port = None
await self._hypervisor.send(f'vm set_aux_tcp_port "{self._name}" {self._aux}')
async def reset_console(self):
"""
+13 -6
View File
@@ -41,6 +41,7 @@ from .utils.iou_import import nvram_import
from .utils.iou_export import nvram_export
from gns3server.compute.ubridge.ubridge_error import UbridgeError
from gns3server.utils.file_watcher import FileWatcher
from gns3server.utils.asyncio.ssh_server import AsyncioSSHServer
from gns3server.utils.asyncio.telnet_server import AsyncioTelnetServer
from gns3server.utils.hostname import is_ios_hostname_valid
from gns3server.utils.asyncio import locking
@@ -643,19 +644,25 @@ class IOUVM(BaseNode):
async def start_console(self):
"""
Start the Telnet server to provide console access.
Start the console server to provide console access.
"""
if self.console and self.console_type == "telnet":
server = AsyncioTelnetServer(
reader=self._iou_process.stdout, writer=self._iou_process.stdin, binary=True, echo=True
)
if self.console and self.console_type in ("telnet", "ssh"):
if self.console_type == "telnet":
server = AsyncioTelnetServer(
reader=self._iou_process.stdout, writer=self._iou_process.stdin, binary=True, echo=True
)
error_prefix = "Telnet"
else:
server = AsyncioSSHServer(reader=self._iou_process.stdout, writer=self._iou_process.stdin)
error_prefix = "SSH"
try:
self._telnet_server = await server.start(self._manager.port_manager.console_host, self.console)
except OSError as e:
await self.stop()
raise IOUError(
"Could not start Telnet server on socket {}:{}: {}".format(
"Could not start {} server on socket {}:{}: {}".format(
error_prefix,
self._manager.port_manager.console_host, self.console, e
)
)
+3 -3
View File
@@ -1922,7 +1922,7 @@ class QemuVM(BaseNode):
def _console_options(self):
if self._console_type == "telnet" and self._wrap_console:
if self._console_type in ("telnet", "ssh") and self._wrap_console:
return self._serial_options(self._internal_console_port, self.console)
elif self._console_type == "vnc":
return self._vnc_options(self.console)
@@ -1938,7 +1938,7 @@ class QemuVM(BaseNode):
if self._aux_type != "none" and self._aux_type == self._console_type:
raise QemuError(f"Auxiliary console type {self._aux_type} cannot be the same as console type")
if self._aux_type == "telnet" and self._wrap_aux:
if self._aux_type in ("telnet", "ssh") and self._wrap_aux:
return self._serial_options(self._internal_aux_port, self.aux)
elif self._aux_type == "vnc":
return self._vnc_options(self.aux)
@@ -2663,7 +2663,7 @@ class QemuVM(BaseNode):
await self._clear_save_vm_stated()
else:
command.extend(await self._saved_state_option())
if self._console_type == "telnet":
if self._console_type in ("telnet", "ssh"):
command.extend(await self._disable_graphics())
if self._tpm:
command.extend(self._tpm_options())
@@ -30,6 +30,7 @@ import tempfile
import xml.etree.ElementTree as ET
from gns3server.utils import parse_version
from gns3server.utils.asyncio.ssh_server import AsyncioSSHServer
from gns3server.utils.asyncio.telnet_server import AsyncioTelnetServer
from gns3server.utils.asyncio.serial import asyncio_open_serial
from gns3server.utils.asyncio import locking
@@ -1011,20 +1012,25 @@ class VirtualBoxVM(BaseNode):
Starts remote console support for this VM.
"""
if self.console and self.console_type == "telnet":
if self.console and self.console_type in ("telnet", "ssh"):
pipe_name = self._get_pipe_name()
try:
self._remote_pipe = await asyncio_open_serial(pipe_name)
except OSError as e:
raise VirtualBoxError(f"Could not open serial pipe '{pipe_name}': {e}")
server = AsyncioTelnetServer(reader=self._remote_pipe, writer=self._remote_pipe, binary=True, echo=True)
if self.console_type == "telnet":
server = AsyncioTelnetServer(reader=self._remote_pipe, writer=self._remote_pipe, binary=True, echo=True)
transport = "Telnet"
else:
server = AsyncioSSHServer(reader=self._remote_pipe, writer=self._remote_pipe)
transport = "SSH"
try:
self._telnet_server = await server.start(self._manager.port_manager.console_host, self.console)
except OSError as e:
self.project.emit(
"log.warning",
{
"message": f"Could not start Telnet server on socket {self._manager.port_manager.console_host}:{self.console}: {e}"
"message": f"Could not start {transport} server on socket {self._manager.port_manager.console_host}:{self.console}: {e}"
},
)
+9 -3
View File
@@ -24,6 +24,7 @@ import asyncio
import tempfile
import platform
from gns3server.utils.asyncio.ssh_server import AsyncioSSHServer
from gns3server.utils.asyncio.telnet_server import AsyncioTelnetServer
from gns3server.utils.asyncio.serial import asyncio_open_serial
from gns3server.utils import parse_version
@@ -912,20 +913,25 @@ class VMwareVM(BaseNode):
Starts remote console support for this VM.
"""
if self.console and self.console_type == "telnet":
if self.console and self.console_type in ("telnet", "ssh"):
pipe_name = self._get_pipe_name()
try:
self._remote_pipe = await asyncio_open_serial(self._get_pipe_name())
except OSError as e:
raise VMwareError(f"Could not open serial pipe '{pipe_name}': {e}")
server = AsyncioTelnetServer(reader=self._remote_pipe, writer=self._remote_pipe, binary=True, echo=True)
if self.console_type == "telnet":
server = AsyncioTelnetServer(reader=self._remote_pipe, writer=self._remote_pipe, binary=True, echo=True)
transport = "Telnet"
else:
server = AsyncioSSHServer(reader=self._remote_pipe, writer=self._remote_pipe)
transport = "SSH"
try:
self._telnet_server = await server.start(self._manager.port_manager.console_host, self.console)
except OSError as e:
self.project.emit(
"log.warning",
{
"message": f"Could not start Telnet server on socket {self._manager.port_manager.console_host}:{self.console}: {e}"
"message": f"Could not start {transport} server on socket {self._manager.port_manager.console_host}:{self.console}: {e}"
},
)