From 729eb43115df29279abf3847b9f401a160b8304b Mon Sep 17 00:00:00 2001 From: Mark Paronyan Date: Sat, 28 Feb 2026 21:45:00 +0300 Subject: [PATCH 01/12] fix(controller,port): handle None port name to prevent TypeError in short_name method --- gns3server/controller/ports/port.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/gns3server/controller/ports/port.py b/gns3server/controller/ports/port.py index 601e8024f..5abdb3034 100644 --- a/gns3server/controller/ports/port.py +++ b/gns3server/controller/ports/port.py @@ -84,9 +84,9 @@ class Port: # If port name format has changed we use the port name as the short name (1.X behavior) if self._short_name: return self._short_name - elif '/' in self._name: + elif self._name and '/' in self._name: return self._name.replace(self.long_name_type(), self.short_name_type()) - elif self._name.startswith("{}{}".format(self.long_name_type(), self._interface_number)): + elif self._name and self._name.startswith("{}{}".format(self.long_name_type(), self._interface_number)): return self.short_name_type() + "{}".format(self._interface_number) return self._name From 0da1a6639af08aafc4b18c2089f6e0bcb88051a9 Mon Sep 17 00:00:00 2001 From: Mark Paronyan Date: Sat, 28 Feb 2026 21:45:08 +0300 Subject: [PATCH 02/12] tests(controller,port): short_name finishes successfully when port name is None --- tests/controller/test_node_port_name.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/tests/controller/test_node_port_name.py b/tests/controller/test_node_port_name.py index 03b4c4d87..974e40565 100644 --- a/tests/controller/test_node_port_name.py +++ b/tests/controller/test_node_port_name.py @@ -132,6 +132,13 @@ def test_list_ports_port_name_format(node): assert node.__json__()["ports"][1]["name"] == "eth0/0" +def test_short_name_none(): + """ + Test short_name does not raise exception when name is None + """ + assert EthernetPort(None, 0, 0, 0).short_name is None + + def test_list_ports_adapters(node): """ List port using adapters properties From 590eedeb464b6a1b37ec249394522af83e725b91 Mon Sep 17 00:00:00 2001 From: Raizo62 Date: Wed, 4 Mar 2026 14:26:52 +0100 Subject: [PATCH 03/12] Fix QEMU serial console artifacts by filtering ANSI CPR responses in telnet proxy - filter incoming cursor position report sequences (`ESC[;R`) in `AsyncioTelnetServer` - prevent stray `;1R;80R` characters on late telnet console attach after VM boot - keep QEMU serial transport unchanged (`-serial telnet`) for BIOS compatibility --- gns3server/utils/asyncio/telnet_server.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/gns3server/utils/asyncio/telnet_server.py b/gns3server/utils/asyncio/telnet_server.py index b13c405cd..092b8605a 100644 --- a/gns3server/utils/asyncio/telnet_server.py +++ b/gns3server/utils/asyncio/telnet_server.py @@ -20,6 +20,7 @@ import socket import asyncio import asyncio.subprocess import struct +import re import logging log = logging.getLogger(__name__) @@ -57,6 +58,7 @@ NAWS = 31 # Negotiate About Window Size LINEMO = 34 # Line Mode READ_SIZE = 1024 +CPR_RESPONSE = re.compile(br"\x1b\[[0-9]{1,4}(;[0-9]{1,4})?R") class TelnetConnection(object): @@ -286,6 +288,11 @@ class AsyncioTelnetServer: if IAC in data: data = await self._IAC_parser(data, network_reader, network_writer, connection) + # Some terminal clients may send ANSI cursor position reports + # (e.g. ESC[1;80R) when attaching to a serial console. Those + # responses can show up as spurious characters at the shell prompt. + data = CPR_RESPONSE.sub(b"", data) + if len(data) == 0: continue From 14ffa2c2d61a224db925e4c31287f034934b7286 Mon Sep 17 00:00:00 2001 From: Jeremy Grossmann Date: Sat, 7 Mar 2026 22:22:16 +0800 Subject: [PATCH 04/12] =?UTF-8?q?Revert=20"Fix=20QEMU=20serial=20console?= =?UTF-8?q?=20artifacts=20by=20filtering=20ANSI=20CPR=20responses=20in=20?= =?UTF-8?q?=E2=80=A6"?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- gns3server/utils/asyncio/telnet_server.py | 7 ------- 1 file changed, 7 deletions(-) diff --git a/gns3server/utils/asyncio/telnet_server.py b/gns3server/utils/asyncio/telnet_server.py index 092b8605a..b13c405cd 100644 --- a/gns3server/utils/asyncio/telnet_server.py +++ b/gns3server/utils/asyncio/telnet_server.py @@ -20,7 +20,6 @@ import socket import asyncio import asyncio.subprocess import struct -import re import logging log = logging.getLogger(__name__) @@ -58,7 +57,6 @@ NAWS = 31 # Negotiate About Window Size LINEMO = 34 # Line Mode READ_SIZE = 1024 -CPR_RESPONSE = re.compile(br"\x1b\[[0-9]{1,4}(;[0-9]{1,4})?R") class TelnetConnection(object): @@ -288,11 +286,6 @@ class AsyncioTelnetServer: if IAC in data: data = await self._IAC_parser(data, network_reader, network_writer, connection) - # Some terminal clients may send ANSI cursor position reports - # (e.g. ESC[1;80R) when attaching to a serial console. Those - # responses can show up as spurious characters at the shell prompt. - data = CPR_RESPONSE.sub(b"", data) - if len(data) == 0: continue From ffcfa4ccb124b27afa788c3ab5b629e4be9bf89b Mon Sep 17 00:00:00 2001 From: YueGuobin Date: Sat, 14 Mar 2026 23:59:36 +0800 Subject: [PATCH 05/12] feat(telnet_server): improve error handling and connection management - Catch OSError alongside ConnectionError in connection processing to handle more network errors - Move client_info retrieval inside try block to prevent AttributeError on failed connections - Replace bare except with specific exceptions (OSError, ConnectionError, asyncio.TimeoutError) - Improve error logging to include specific exception details and client information --- gns3server/utils/asyncio/telnet_server.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/gns3server/utils/asyncio/telnet_server.py b/gns3server/utils/asyncio/telnet_server.py index b13c405cd..cc34a29a6 100644 --- a/gns3server/utils/asyncio/telnet_server.py +++ b/gns3server/utils/asyncio/telnet_server.py @@ -215,7 +215,7 @@ class AsyncioTelnetServer: await self._write_intro(network_writer, echo=self._echo, binary=self._binary, naws=self._naws) await connection.connected() await self._process(network_reader, network_writer, connection) - except ConnectionError: + except (ConnectionError, OSError): async with self._lock: network_writer.close() # await network_writer.wait_closed() # this doesn't work in Python 3.6 @@ -308,14 +308,15 @@ class AsyncioTelnetServer: # Replicate the output on all clients for connection_key in list(self._connections.keys()): - client_info = connection_key.get_extra_info("socket").getpeername() connection = self._connections[connection_key] + client_info = None try: + client_info = connection_key.get_extra_info("socket").getpeername() connection.writer.write(data) await asyncio.wait_for(connection.writer.drain(), timeout=10) - except: - log.debug(f"Timeout while sending data to client: {client_info}, closing and removing from connection table.") + except (OSError, ConnectionError, asyncio.TimeoutError) as e: + log.debug(f"Error sending data to client {client_info}: {e}, closing and removing from connection table.") connection.close() del self._connections[connection_key] From 646ff00ade2c1a55cac976b139be1c8abf061eeb Mon Sep 17 00:00:00 2001 From: grossmj Date: Mon, 23 Mar 2026 09:37:15 +0800 Subject: [PATCH 06/12] Sync appliances --- gns3server/appliances/alpinet.gns3a | 21 ++ .../asterfusion-vAsterNOS-VPP.gns3a | 22 +- gns3server/appliances/cisco-iou-l2.gns3a | 52 ++--- gns3server/appliances/cisco-iou-l3.gns3a | 40 ++-- gns3server/appliances/home-assistant.gns3a | 60 ++++++ gns3server/appliances/infix.gns3a | 28 +++ .../appliances/juniper-vJunos-router.gns3a | 6 +- gns3server/appliances/openwrt.gns3a | 15 ++ gns3server/appliances/opnsense.gns3a | 27 +++ gns3server/appliances/rhel.gns3a | 201 +++++------------- gns3server/appliances/tinycore-linux.gns3a | 16 +- .../appliances/windows-11-dev-env.gns3a | 29 +-- 12 files changed, 291 insertions(+), 226 deletions(-) create mode 100644 gns3server/appliances/alpinet.gns3a create mode 100644 gns3server/appliances/home-assistant.gns3a diff --git a/gns3server/appliances/alpinet.gns3a b/gns3server/appliances/alpinet.gns3a new file mode 100644 index 000000000..7a7ecacbe --- /dev/null +++ b/gns3server/appliances/alpinet.gns3a @@ -0,0 +1,21 @@ +{ + "appliance_id": "a2209414-88a6-403b-a2e1-477a057ec954", + "name": "AlpiNet", + "category": "guest", + "description": "AlpiNet is a lightweight Alpine-based networking toolbox for GNS3.\n\nCOMPREHENSIVE TOOLKIT:\n• Network: ip, ifconfig, ping, traceroute, mtr, arping, nmap, tcpdump\n• Performance: iperf, iperf3\n• HTTP/Web: curl, wget\n• TCP/UDP: netcat, socat, telnet\n• Advanced: ethtool, bridge-utils, vlan\n• Firewall: iptables, ip6tables, nftables\n• File Transfer: FTP (lftp), TFTP, SSH (scp/sftp), rsync\n• DNS: host, nslookup, dig\n• System: nano, tmux, screen, htop, bash\n• Text Tools: grep, sed, awk, jq, less\n• Compression: tar, gzip\n• Utilities: tree, file, openssl\n\nType 'alpinet-tools' in the container for the complete tool list.\n\nOptimized for network testing, troubleshooting, and education.", + "vendor_name": "AlpiNet Project", + "vendor_url": "https://www.alpinelinux.org/", + "product_name": "AlpiNet", + "registry_version": 4, + "status": "stable", + "maintainer": "nazDridoy", + "maintainer_email": "nazdridoy399@gmail.com", + "usage": "The /root directory is persistent across restarts. Type 'alpinet-tools' to see all available utilities.", + "symbol": "AlpiNet.svg", + "docker": { + "adapters": 1, + "image": "gns3/alpinet:latest", + "console_type": "telnet", + "environment": "TERM=xterm" + } +} diff --git a/gns3server/appliances/asterfusion-vAsterNOS-VPP.gns3a b/gns3server/appliances/asterfusion-vAsterNOS-VPP.gns3a index d8f8ed557..86016b329 100644 --- a/gns3server/appliances/asterfusion-vAsterNOS-VPP.gns3a +++ b/gns3server/appliances/asterfusion-vAsterNOS-VPP.gns3a @@ -1,7 +1,8 @@ { + "appliance_id": "4aa78f74-c769-43e0-9ef1-5dd2cf1909c6", "name": "Asterfusion AsterNOS-VPP", "category": "router", - "description": "AsterNOS-VPP leverages SONiC’s robust routing features and management capability with VPP’s high-performance data forwarding. It supports L3 routing, NAT, PPPoE, and VPN services. Minimum requirements: 4 vCPUs and 4GB RAM.", + "description": "AsterNOS-VPP leverages SONiC’s robust routing features and management capability with VPP’s high-performance data forwarding. It supports L3 routing, NAT, PPPoE, and VPN services. Minimum requirements: 4 vCPUs and 8GB RAM.", "vendor_name": "Asterfusion", "vendor_url": "https://cloudswit.ch/product/sonic-enterprise-distribution/#vAsterNOS", "documentation_url": "https://docs.asternos.com/routing", @@ -10,23 +11,32 @@ "status": "stable", "maintainer": "Asterfusion Product Team", "maintainer_email": "bd@cloudswit.ch", + "availability": "with-registration", "images": [ { - "filename": "AsterNOS-VPP_V6.1-R0101P02_x86.img.gz", - "version": "V6.1", + "filename": "AsterNOS-VPP_V6.1-R0101P02_x86.img", + "version": "6.1", "md5sum": "55834c3e8849ab226144d79ef76acf81", "filesize": 1318201015, "download_url": "https://asternos.dev/api/file/b2c9fd83-a9d3-46f3-9676-6b49dd481d6c", - "comments": "Registration is required to download the image." + "compression": "gzip" } ], "qemu": { "adapter_type": "virtio-net-pci", "adapters": 4, - "ram": 4096, + "ram": 8192, "cpus": 4, "arch": "x86_64", "console_type": "telnet", "kvm": "require" - } + }, + "versions": [ + { + "name": "6.1", + "images": { + "hda_disk_image": "AsterNOS-VPP_V6.1-R0101P02_x86.img" + } + } + ] } diff --git a/gns3server/appliances/cisco-iou-l2.gns3a b/gns3server/appliances/cisco-iou-l2.gns3a index 0f6613ac3..daf98d7da 100644 --- a/gns3server/appliances/cisco-iou-l2.gns3a +++ b/gns3server/appliances/cisco-iou-l2.gns3a @@ -15,11 +15,17 @@ "iou": { "ethernet_adapters": 4, "serial_adapters": 0, - "nvram": 512, - "ram": 512, + "nvram": 256, + "ram": 1024, "startup_config": "iou_l2_base_startup-config.txt" }, "images": [ + { + "filename": "x86_64_crb_linux_l2-adventerprisek9-ms.17.16.1a.iol", + "version": "17.16.1a", + "md5sum": "d55879318e51c5401fb9314d45c67728", + "filesize": 244265240 + }, { "filename": "x86_64_crb_linux_l2-adventerprisek9-ms.iol", "version": "17.15.1", @@ -33,25 +39,25 @@ "filesize": 240355720 }, { - "filename": "i86bi-linux-l2-ipbasek9-15.1g.bin", - "version": "15.1g", - "md5sum": "0b8b9e14ca99b68c654e44c4296857ba", - "filesize": 62137336 + "filename": "i86bi_linux_l2-adventerprisek9-ms.SSA.high_iron_20190423.bin", + "version": "15.2(20190423)", + "md5sum": "ffe884f8e762b762d7886c51d8ee015c", + "filesize": 126249700 }, { - "filename": "i86bi-linux-l2-adventerprisek9-15.1a.bin", - "version": "15.1a", - "md5sum": "9549a20a7391fb849da32caa77a0d254", - "filesize": 72726092 - }, - { - "filename": "i86bi-linux-l2-adventerprisek9-15.2d.bin", - "version": "15.2d", - "md5sum": "f16db44433beb3e8c828db5ddad1de8a", - "filesize": 105036380 + "filename": "i86bi_LinuxL2-AdvEnterpriseK9-M_152_May_2018.bin", + "version": "15.2(20180510)", + "md5sum": "d704b68c5f4c4f92e56b754b49b92012", + "filesize": 126226692 } ], "versions": [ + { + "name": "17.16.1a", + "images": { + "image": "x86_64_crb_linux_l2-adventerprisek9-ms.17.16.1a.iol" + } + }, { "name": "17.15.1", "images": { @@ -65,21 +71,15 @@ } }, { - "name": "15.1g", + "name": "15.2(20190423)", "images": { - "image": "i86bi-linux-l2-ipbasek9-15.1g.bin" + "image": "i86bi_linux_l2-adventerprisek9-ms.SSA.high_iron_20190423.bin" } }, { - "name": "15.1a", + "name": "15.2(20180510)", "images": { - "image": "i86bi-linux-l2-adventerprisek9-15.1a.bin" - } - }, - { - "name": "15.2d", - "images": { - "image": "i86bi-linux-l2-adventerprisek9-15.2d.bin" + "image": "i86bi_LinuxL2-AdvEnterpriseK9-M_152_May_2018.bin" } } ] diff --git a/gns3server/appliances/cisco-iou-l3.gns3a b/gns3server/appliances/cisco-iou-l3.gns3a index 262019098..06935482a 100644 --- a/gns3server/appliances/cisco-iou-l3.gns3a +++ b/gns3server/appliances/cisco-iou-l3.gns3a @@ -15,11 +15,17 @@ "iou": { "ethernet_adapters": 2, "serial_adapters": 2, - "nvram": 512, - "ram": 512, + "nvram": 256, + "ram": 1024, "startup_config": "iou_l3_base_startup-config.txt" }, "images": [ + { + "filename": "x86_64_crb_linux-adventerprisek9-ms.17.16.1a.iol", + "version": "17.16.1a", + "md5sum": "39bdf959c3b99a3c81d66451f1cf0404", + "filesize": 293062920 + }, { "filename": "x86_64_crb_linux-adventerprisek9-ms.iol", "version": "17.15.1", @@ -37,21 +43,15 @@ "version": "15.7(3)M2", "md5sum": "d6874260c3daeeb96d10fc844ae0b93b", "filesize": 184759244 - }, - { - "filename": "i86bi-linux-l3-adventerprisek9-ms.155-2.T.bin", - "version": "155-2T", - "md5sum": "45e99761a95cbd3ee3924ecf0f3d89e5", - "filesize": 172982492 - }, - { - "filename": "i86bi-linux-l3-adventerprisek9-15.4.1T.bin", - "version": "15.4.1T", - "md5sum": "2eabae17778316c49cbc80e8e81262f9", - "filesize": 152677848 } ], "versions": [ + { + "name": "17.16.1a", + "images": { + "image": "x86_64_crb_linux-adventerprisek9-ms.17.16.1a.iol" + } + }, { "name": "17.15.1", "images": { @@ -69,18 +69,6 @@ "images": { "image": "i86bi_LinuxL3-AdvEnterpriseK9-M2_157_3_May_2018.bin" } - }, - { - "name": "155-2T", - "images": { - "image": "i86bi-linux-l3-adventerprisek9-ms.155-2.T.bin" - } - }, - { - "name": "15.4.1T", - "images": { - "image": "i86bi-linux-l3-adventerprisek9-15.4.1T.bin" - } } ] } diff --git a/gns3server/appliances/home-assistant.gns3a b/gns3server/appliances/home-assistant.gns3a new file mode 100644 index 000000000..d6cb3a5ae --- /dev/null +++ b/gns3server/appliances/home-assistant.gns3a @@ -0,0 +1,60 @@ +{ + "appliance_id": "4ac0d5bc-714d-4a5a-8af5-8fb452046acd", + "name": "Home Assistant", + "category": "guest", + "description": "Open source home automation that puts local control and privacy first. Powered by a worldwide community of tinkerers and DIY enthusiasts.", + "vendor_name": "Open Home Foundation", + "vendor_url": "https://www.openhomefoundation.org/", + "vendor_logo_url": "https://www.openhomefoundation.org/assets/images/logo/open-home-foundation.svg", + "documentation_url": "https://www.home-assistant.io/installation/alternative", + "product_name": "haos", + "product_url": "https://www.home-assistant.io/", + "registry_version": 8, + "status": "stable", + "availability": "free", + "maintainer": "Neyder Achahuanco Apaza", + "maintainer_email": "gns3@neyder.net", + "usage": "You will be able to reach Home Assistant on homeassistant.local:8123, you might need to access Home Assistant at homeassistant:8123 or http://X.X.X.X:8123 (replace X.X.X.X with your virtual machine\u2019s IP address).\nYou can access trough console with root user without password.", + "symbol": "home-assistant-logomark-color-on-light.svg", + "default_username": "cisco", + "default_password": "admin", + "settings": [ + { + "name": "Default template settings", + "default": true, + "template_type": "qemu", + "template_properties": + { + "adapter_type": "virtio-net-pci", + "platform": "x86_64", + "adapters": 1, + "ram": 2048, + "cpus": 2, + "hda_disk_interface": "virtio", + "console_type": "telnet", + "boot_priority": "cd", + "on_close": "shutdown_signal", + "uefi": true + } + } + ], + "images": [ + { + "filename": "haos_ova-17.1.qcow2", + "version": "17.1", + "md5sum": "e1b950430045859a3ac035cb420df158", + "filesize": 1024393216, + "download_url": "https://www.home-assistant.io/installation/alternative", + "direct_download_url": "https://github.com/home-assistant/operating-system/releases/download/17.1/haos_ova-17.1.qcow2.xz", + "compression": "xz" + } + ], + "versions": [ + { + "images": { + "hda_disk_image": "haos_ova-17.1.qcow2" + }, + "name": "17.1" + } + ] +} \ No newline at end of file diff --git a/gns3server/appliances/infix.gns3a b/gns3server/appliances/infix.gns3a index d9ab0321f..f28a3d6a7 100644 --- a/gns3server/appliances/infix.gns3a +++ b/gns3server/appliances/infix.gns3a @@ -111,9 +111,37 @@ "md5sum": "e2eb1a4fff9d56815f37c2e82a6279fb", "version": "25.11.0", "direct_download_url": "https://github.com/kernelkit/infix/releases/download/v25.11.0/infix-x86_64-disk-25.11.0.qcow2" + }, + { + "filename": "infix-x86_64-v26.01.0.qcow2", + "filesize": 307363840, + "md5sum": "bd3b9c56f98ba264c36e4d6f69931546", + "version": "26.01.0", + "direct_download_url": "https://github.com/kernelkit/infix/releases/download/v26.01.0/infix-x86_64-v26.01.0.qcow2" + }, + { + "filename": "infix-x86_64-v26.02.1.qcow2", + "filesize": 316407808, + "md5sum": "0c4da508cfcb702c066f47227644b65f", + "version": "26.02.1", + "direct_download_url": "https://github.com/kernelkit/infix/releases/download/v26.02.1/infix-x86_64-v26.02.1.qcow2" } ], "versions": [ + { + "name": "26.02.1", + "images": { + "bios_image": "OVMF-edk2-stable202305.fd", + "hda_disk_image": "infix-x86_64-v26.02.1.qcow2" + } + }, + { + "name": "26.01.0", + "images": { + "bios_image": "OVMF-edk2-stable202305.fd", + "hda_disk_image": "infix-x86_64-v26.01.0.qcow2" + } + }, { "name": "25.11.0", "images": { diff --git a/gns3server/appliances/juniper-vJunos-router.gns3a b/gns3server/appliances/juniper-vJunos-router.gns3a index 9669079ac..8fecd0681 100644 --- a/gns3server/appliances/juniper-vJunos-router.gns3a +++ b/gns3server/appliances/juniper-vJunos-router.gns3a @@ -14,11 +14,11 @@ "maintainer_email": "github@sugarpapa.mozmail.com", "usage": "GNS3 SHOULD be a baremetal installation. Using the GNS3 VM MIGHT result in unwanted issues. Default user is root. No password is needed.", "symbol": "juniper-vmx.svg", - "first_port_name": "ge-0/0/0", + "first_port_name": "fxp0", "port_name_format": "ge-0/0/{port0}", "qemu": { "adapter_type": "virtio-net-pci", - "adapters": 17, + "adapters": 10, "ram": 5120, "cpus": 4, "hda_disk_interface": "virtio", @@ -72,4 +72,4 @@ "name": "23.2R1.15" } ] -} \ No newline at end of file +} diff --git a/gns3server/appliances/openwrt.gns3a b/gns3server/appliances/openwrt.gns3a index ac3711d3a..84e9e6b57 100644 --- a/gns3server/appliances/openwrt.gns3a +++ b/gns3server/appliances/openwrt.gns3a @@ -23,6 +23,15 @@ "kvm": "allow" }, "images": [ + { + "filename": "openwrt-25.12.0-x86-64-generic-ext4-combined.img", + "version": "25.12.0", + "md5sum": "8c0e01307fc47c1f862a5d0c9a51c2c5", + "filesize": 126353408, + "download_url": "https://downloads.openwrt.org/releases/25.12.0/targets/x86/64/", + "direct_download_url": "https://downloads.openwrt.org/releases/25.12.0/targets/x86/64/openwrt-25.12.0-x86-64-generic-ext4-combined.img.gz", + "compression": "gzip" + }, { "filename": "openwrt-24.10.2-x86-64-generic-ext4-combined.img", "version": "24.10.2", @@ -232,6 +241,12 @@ } ], "versions": [ + { + "name": "25.12.0", + "images": { + "hda_disk_image": "openwrt-25.12.0-x86-64-generic-ext4-combined.img" + } + }, { "name": "24.10.2", "images": { diff --git a/gns3server/appliances/opnsense.gns3a b/gns3server/appliances/opnsense.gns3a index 433b4f2de..7d3756052 100644 --- a/gns3server/appliances/opnsense.gns3a +++ b/gns3server/appliances/opnsense.gns3a @@ -25,6 +25,15 @@ "kvm": "require" }, "images": [ + { + "filename": "OPNsense-26.1-nano-amd64.img", + "version": "26.1", + "md5sum": "3b774e8fb651a5d00aa6fa3e0d1b1ff0", + "filesize": 3221225472, + "download_url": "https://opnsense.c0urier.net/releases/26.1/", + "direct_download_url": "https://opnsense.c0urier.net/releases/26.1/OPNsense-26.1-nano-amd64.img.bz2", + "compression": "bzip2" + }, { "filename": "OPNsense-25.7-nano-amd64.img", "version": "25.7", @@ -90,6 +99,24 @@ } ], "versions": [ + { + "name": "26.1", + "images": { + "hda_disk_image": "OPNsense-26.1-nano-amd64.img" + } + }, + { + "name": "25.7", + "images": { + "hda_disk_image": "OPNsense-25.7-nano-amd64.img" + } + }, + { + "name": "25.1", + "images": { + "hda_disk_image": "OPNsense-25.1-nano-amd64.img" + } + }, { "name": "24.7", "images": { diff --git a/gns3server/appliances/rhel.gns3a b/gns3server/appliances/rhel.gns3a index c383c4b4d..8ee8916a6 100644 --- a/gns3server/appliances/rhel.gns3a +++ b/gns3server/appliances/rhel.gns3a @@ -13,11 +13,11 @@ "availability": "service-contract", "maintainer": "Da-Geek", "maintainer_email": "dageek@dageeks-geeks.gg", - "usage": "You should download Red Hat Enterprise Linux KVM Guest Image from https://access.redhat.com/downloads/content/479/ver=/rhel---9/9.5/x86_64/product-software attach/customize rhel-cloud-init.iso and start.\nusername: cloud-user\npassword: redhat", + "usage": "You should download Red Hat Enterprise Linux KVM Guest Image from https://access.redhat.com/downloads/content/rhel attach/customize rhel-cloud-init.iso and start.\nusername: cloud-user\npassword: redhat", "qemu": { "adapter_type": "virtio-net-pci", "adapters": 1, - "ram": 1024, + "ram": 1536, "hda_disk_interface": "virtio", "arch": "x86_64", "console_type": "telnet", @@ -27,11 +27,32 @@ }, "images": [ { - "filename": "rhel-9.5-x86_64-kvm.qcow2", - "version": "9.5", - "md5sum": "8174396d5cb47727c59dd04dd9a05418", - "filesize": 974389248, - "download_url": "https://access.redhat.com/downloads/content/479/ver=/rhel---9/9.5/x86_64/product-software" + "filename": "rhel-10.1-x86_64-kvm.qcow2", + "version": "10.1", + "md5sum": "b2f35b1ac0600de31cadf30a622a2fef", + "filesize": 1010827264, + "download_url": "https://access.redhat.com/downloads/content/479/ver=/rhel---10/10.1/x86_64/product-software" + }, + { + "filename": "rhel-10.0-x86_64-kvm.qcow2", + "version": "10.0", + "md5sum": "ea5918fa116ec53f9ef1ce4be69ae2be", + "filesize": 855506944, + "download_url": "https://access.redhat.com/downloads/content/479/ver=/rhel---10/10.0/x86_64/product-software" + }, + { + "filename": "rhel-9.7-x86_64-kvm.qcow2", + "version": "9.7", + "md5sum": "abc2f42cc5daeea6b03d0d2d478a0d1c", + "filesize": 1229979648, + "download_url": "https://access.redhat.com/downloads/content/479/ver=/rhel---9/9.7/x86_64/product-software" + }, + { + "filename": "rhel-9.6-x86_64-kvm.qcow2", + "version": "9.6", + "md5sum": "056648d716275053ebe77b1d4f68c862", + "filesize": 1060438016, + "download_url": "https://access.redhat.com/downloads/content/479/ver=/rhel---9/9.6/x86_64/product-software" }, { "filename": "rhel-9.4-x86_64-kvm.qcow2", @@ -40,34 +61,6 @@ "filesize": 957218816, "download_url": "https://access.redhat.com/downloads/content/479/ver=/rhel---9/9.4/x86_64/product-software" }, - { - "filename": "rhel-9.3-x86_64-kvm.qcow2", - "version": "9.3", - "md5sum": "409d8d15f5177db2617b0e3e02139b5c", - "filesize": 858193920, - "download_url": "https://access.redhat.com/downloads/content/479/ver=/rhel---9/9.3/x86_64/product-software" - }, - { - "filename": "rhel-9.2-x86_64-kvm.qcow2", - "version": "9.2", - "md5sum": "f33845298b387dbcfbf162c6b4e3f8c8", - "filesize": 819265536, - "download_url": "https://access.redhat.com/downloads/content/479/ver=/rhel---8/9.2/x86_64/product-software" - }, - { - "filename": "rhel-baseos-9.1-x86_64-kvm.qcow2", - "version": "9.1", - "md5sum": "622de743da83bcec1ad2959ecaedb8f4", - "filesize": 753401856, - "download_url": "https://access.redhat.com/downloads/content/479/ver=/rhel---8/9.1/x86_64/product-software" - }, - { - "filename": "rhel-baseos-9.0-x86_64-kvm.qcow2", - "version": "9.0", - "md5sum": "4a41497d354fe99a4abf55f1ed73edcb", - "filesize": 696582144, - "download_url": "https://access.redhat.com/downloads/content/479/ver=/rhel---8/9.0/x86_64/product-software" - }, { "filename": "rhel-8.10-x86_64-kvm.qcow2", "version": "8.10", @@ -75,13 +68,6 @@ "filesize": 1065091072, "download_url": "https://access.redhat.com/downloads/content/479/ver=/rhel---8/8.10/x86_64/product-software" }, - { - "filename": "rhel-8.9-x86_64-kvm.qcow2", - "version": "8.9", - "md5sum": "23295fe508678cbdebfbdbd41ef6e6e2", - "filesize": 971833344, - "download_url": "https://access.redhat.com/downloads/content/479/ver=/rhel---8/8.9/x86_64/product-software" - }, { "filename": "rhel-8.8-x86_64-kvm.qcow2", "version": "8.8", @@ -89,13 +75,6 @@ "filesize": 926810112, "download_url": "https://access.redhat.com/downloads/content/479/ver=/rhel---8/8.8/x86_64/product-software" }, - { - "filename": "rhel-8.7-x86_64-kvm.qcow2", - "version": "8.7", - "md5sum": "ab71a2c4cc276441bf999f531e064507", - "filesize": 858128384, - "download_url": "https://access.redhat.com/downloads/content/479/ver=/rhel---8/8.7/x86_64/product-software" - }, { "filename": "rhel-8.6-x86_64-kvm.qcow2", "version": "8.6", @@ -104,31 +83,10 @@ "download_url": "https://access.redhat.com/downloads/content/479/ver=/rhel---8/8.6/x86_64/product-software" }, { - "filename": "rhel-8.5-x86_64-kvm.qcow2", - "version": "8.5", - "md5sum": "1efb78dbb2033ba4ac6589a06c95c2d4", - "filesize": 779419648, - "download_url": "https://access.redhat.com/downloads/content/479/ver=/rhel---8/8.5/x86_64/product-software" - }, - { - "filename": "rhel-8.4-x86_64-kvm.qcow2", - "version": "8.4", - "md5sum": "db4c3a72857b784dc6e96120351f2894", - "filesize": 727449600, - "download_url": "https://access.redhat.com/downloads/content/479/ver=/rhel---8/8.4/x86_64/product-software" - }, - { - "filename": "rhel-8.3-x86_64-kvm.qcow2", - "version": "8.3", - "md5sum": "dd554c059e0910379fff88f677f4a4b3", - "filesize": 1316683776, - "download_url": "https://access.redhat.com/downloads/content/479/ver=/rhel---8/8.3/x86_64/product-software" - }, - { - "filename": "rhel-server-7.9-x86_64-kvm.qcow2", + "filename": "rhel-server-7.9-update-12-x86_64-kvm.qcow2", "version": "7.9", - "md5sum": "8d6669b3e2bb8df15b9b4280936cf950", - "filesize": 827777024, + "md5sum": "f77fc7e3cf31a210a8244e486466ce34", + "filesize": 838036992, "download_url": "https://access.redhat.com/downloads/content/69/ver=/rhel---7/7.9/x86_64/product-software" }, { @@ -140,17 +98,39 @@ }, { "filename": "rhel-cloud-init.iso", - "version": "1.0", - "md5sum": "421745b0d13615ecd48696f98d8b6352", + "version": "1.1", + "md5sum": "f1f908d36e67f843dd94db1c2e8c8373", "filesize": 374784, - "download_url": "https://gitlab.com/neyder/rhel-cloud-init/raw/master/rhel-cloud-init.iso" + "download_url": "https://github.com/GNS3/gns3-registry/tree/master/cloud-init/rhel", + "direct_download_url": "https://github.com/GNS3/gns3-registry/raw/master/cloud-init/rhel/rhel-cloud-init.iso" } ], "versions": [ { - "name": "9.5", + "name": "10.1", "images": { - "hda_disk_image": "rhel-9.5-x86_64-kvm.qcow2", + "hda_disk_image": "rhel-10.1-x86_64-kvm.qcow2", + "cdrom_image": "rhel-cloud-init.iso" + } + }, + { + "name": "10.0", + "images": { + "hda_disk_image": "rhel-10.0-x86_64-kvm.qcow2", + "cdrom_image": "rhel-cloud-init.iso" + } + }, + { + "name": "9.7", + "images": { + "hda_disk_image": "rhel-9.7-x86_64-kvm.qcow2", + "cdrom_image": "rhel-cloud-init.iso" + } + }, + { + "name": "9.6", + "images": { + "hda_disk_image": "rhel-9.6-x86_64-kvm.qcow2", "cdrom_image": "rhel-cloud-init.iso" } }, @@ -161,34 +141,6 @@ "cdrom_image": "rhel-cloud-init.iso" } }, - { - "name": "9.3", - "images": { - "hda_disk_image": "rhel-9.3-x86_64-kvm.qcow2", - "cdrom_image": "rhel-cloud-init.iso" - } - }, - { - "name": "9.2", - "images": { - "hda_disk_image": "rhel-9.2-x86_64-kvm.qcow2", - "cdrom_image": "rhel-cloud-init.iso" - } - }, - { - "name": "9.1", - "images": { - "hda_disk_image": "rhel-baseos-9.1-x86_64-kvm.qcow2", - "cdrom_image": "rhel-cloud-init.iso" - } - }, - { - "name": "9.0", - "images": { - "hda_disk_image": "rhel-baseos-9.0-x86_64-kvm.qcow2", - "cdrom_image": "rhel-cloud-init.iso" - } - }, { "name": "8.10", "images": { @@ -196,13 +148,6 @@ "cdrom_image": "rhel-cloud-init.iso" } }, - { - "name": "8.9", - "images": { - "hda_disk_image": "rhel-8.9-x86_64-kvm.qcow2", - "cdrom_image": "rhel-cloud-init.iso" - } - }, { "name": "8.8", "images": { @@ -210,13 +155,6 @@ "cdrom_image": "rhel-cloud-init.iso" } }, - { - "name": "8.7", - "images": { - "hda_disk_image": "rhel-8.7-x86_64-kvm.qcow2", - "cdrom_image": "rhel-cloud-init.iso" - } - }, { "name": "8.6", "images": { @@ -224,31 +162,10 @@ "cdrom_image": "rhel-cloud-init.iso" } }, - { - "name": "8.5", - "images": { - "hda_disk_image": "rhel-8.5-x86_64-kvm.qcow2", - "cdrom_image": "rhel-cloud-init.iso" - } - }, - { - "name": "8.4", - "images": { - "hda_disk_image": "rhel-8.4-x86_64-kvm.qcow2", - "cdrom_image": "rhel-cloud-init.iso" - } - }, - { - "name": "8.3", - "images": { - "hda_disk_image": "rhel-8.3-x86_64-kvm.qcow2", - "cdrom_image": "rhel-cloud-init.iso" - } - }, { "name": "7.9", "images": { - "hda_disk_image": "rhel-server-7.9-x86_64-kvm.qcow2", + "hda_disk_image": "rhel-server-7.9-update-12-x86_64-kvm.qcow2", "cdrom_image": "rhel-cloud-init.iso" } }, diff --git a/gns3server/appliances/tinycore-linux.gns3a b/gns3server/appliances/tinycore-linux.gns3a index 7fa608326..f5fd5273e 100644 --- a/gns3server/appliances/tinycore-linux.gns3a +++ b/gns3server/appliances/tinycore-linux.gns3a @@ -2,7 +2,7 @@ "appliance_id": "ed6b9f98-7de2-4d61-a3ed-ad4c3e323ace", "name": "Tiny Core Linux", "category": "guest", - "description": "Core Linux is a smaller variant of Tiny Core without a graphical desktop.\n\nIt provides a complete Linux system using only a few MiB.", + "description": "TinyCore is a Linux system with a graphical desktop using only a few MiB.", "vendor_name": "Team Tiny Core", "vendor_url": "http://distro.ibiblio.org/tinycorelinux", "documentation_url": "http://wiki.tinycorelinux.net/", @@ -25,6 +25,14 @@ "options": "-vga std -usbdevice tablet" }, "images": [ + { + "filename": "linux-tinycore-17.0.qcow2", + "version": "17.0", + "md5sum": "6434d4da25dd0277cc90b000bb4ac8f1", + "filesize": 35061760, + "download_url": "https://sourceforge.net/projects/gns-3/files/Qemu%20Appliances/", + "direct_download_url": "http://downloads.sourceforge.net/project/gns-3/Qemu%20Appliances/linux-tinycore-17.0.qcow2" + }, { "filename": "linux-tinycore-11.1.qcow2", "version": "11.1", @@ -51,6 +59,12 @@ } ], "versions": [ + { + "name": "17.0", + "images": { + "hda_disk_image": "linux-tinycore-17.0.qcow2" + } + }, { "name": "11.1", "images": { diff --git a/gns3server/appliances/windows-11-dev-env.gns3a b/gns3server/appliances/windows-11-dev-env.gns3a index b9b453e8a..f6bbecc65 100644 --- a/gns3server/appliances/windows-11-dev-env.gns3a +++ b/gns3server/appliances/windows-11-dev-env.gns3a @@ -30,19 +30,11 @@ }, "images": [ { - "filename": "WinDev2308Eval-disk1.vmdk", - "version": "2308", - "md5sum": "6a9b4ed6d7481f7bbf8a054c797b1eee", - "filesize": 24945341952, - "download_url": "https://download.microsoft.com/download/7/1/3/7135f2ab-8528-49fc-9252-8d5d94c697ef/WinDev2308Eval.VMWare.zip", - "compression": "zip" - }, - { - "filename": "WinDev2212Eval-disk1.vmdk", - "version": "2212", - "md5sum": "c79f393a067b92e01a513a118d455ac8", - "filesize": 24620493824, - "download_url": "https://aka.ms/windev_VM_vmware", + "filename": "WinDev2407Eval-disk1.vmdk", + "version": "2407", + "md5sum": "66e7233c7922ef128c5b7c957d20bcbe", + "filesize": 25984826368, + "download_url": "https://download.microsoft.com/download/e/e/c/eec4775f-f2e8-4476-98b2-ca51502a6429/WinDev2407Eval.VMWare.zip", "compression": "zip" }, { @@ -57,17 +49,10 @@ ], "versions": [ { - "name": "2308", + "name": "2407", "images": { "bios_image": "OVMF-edk2-stable202305.fd", - "hda_disk_image": "WinDev2308Eval-disk1.vmdk" - } - }, - { - "name": "2212", - "images": { - "bios_image": "OVMF-edk2-stable202305.fd", - "hda_disk_image": "WinDev2212Eval-disk1.vmdk" + "hda_disk_image": "WinDev2407Eval-disk1.vmdk" } } ] From 7adb0c52262eebf10a9d4d3aa5fc65155208e930 Mon Sep 17 00:00:00 2001 From: grossmj Date: Mon, 23 Mar 2026 09:45:46 +0800 Subject: [PATCH 07/12] Release v2.2.57 --- CHANGELOG | 10 ++++++++++ gns3server/crash_report.py | 2 +- gns3server/version.py | 4 ++-- 3 files changed, 13 insertions(+), 3 deletions(-) diff --git a/CHANGELOG b/CHANGELOG index 63db2f004..fc3331f3c 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -1,5 +1,15 @@ # Change Log +## 2.2.57 23/03/2026 + +* feat(telnet_server): improve error handling and connection management +* Deactivate 'use default IOU values' by default and update RAM/NVRAM values +* Make sure the node shows as stopped when the wrap console cannot be stopped +* tests(controller,port): short_name finishes successfully when port name is None +* fix(controller,port): handle None port name to prevent TypeError in short_name method +* fix: busybox static link detection on Alpine/musl +* Take populated disks into consideration when calculating PCI device ID + ## 2.2.56.1 28/01/2026 * Fix telnet keepalive options on macOS diff --git a/gns3server/crash_report.py b/gns3server/crash_report.py index 5898bbb7d..d9953ae27 100644 --- a/gns3server/crash_report.py +++ b/gns3server/crash_report.py @@ -57,7 +57,7 @@ class CrashReport: Report crash to a third party service """ - DSN = "https://5bfcec70239c428d44aafef68f3eea79@o19455.ingest.us.sentry.io/38482" + DSN = "https://b3291ad085cbcc85059160e74ce83ab6@o19455.ingest.us.sentry.io/38482" _instance = None def __init__(self): diff --git a/gns3server/version.py b/gns3server/version.py index c520f0086..4cd21a6b3 100644 --- a/gns3server/version.py +++ b/gns3server/version.py @@ -23,8 +23,8 @@ # or negative for a release candidate or beta (after the base version # number has been incremented) -__version__ = "2.2.57.dev2" -__version_info__ = (2, 2, 57, 99) +__version__ = "2.2.57" +__version_info__ = (2, 2, 57, 0) if "dev" in __version__: try: From e5ff9e7f00e59b23ba8474efd06724ecfa645837 Mon Sep 17 00:00:00 2001 From: grossmj Date: Tue, 24 Mar 2026 08:35:49 +0800 Subject: [PATCH 08/12] Development on 2.2.58.dev1 --- gns3server/version.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/gns3server/version.py b/gns3server/version.py index 4cd21a6b3..99bc98024 100644 --- a/gns3server/version.py +++ b/gns3server/version.py @@ -23,8 +23,8 @@ # or negative for a release candidate or beta (after the base version # number has been incremented) -__version__ = "2.2.57" -__version_info__ = (2, 2, 57, 0) +__version__ = "2.2.58.dev1" +__version_info__ = (2, 2, 58, 99) if "dev" in __version__: try: From 7684ce60ce9cb0de398088ce6d4550cb3474c374 Mon Sep 17 00:00:00 2001 From: Cristi Date: Tue, 24 Mar 2026 19:23:45 +0800 Subject: [PATCH 09/12] Backport: Re-write current Telnet server implementation using telnetlib3 library --- gns3server/compute/base_node.py | 6 +- gns3server/compute/docker/docker_vm.py | 4 +- gns3server/compute/iou/iou_vm.py | 3 +- .../compute/virtualbox/virtualbox_vm.py | 2 +- gns3server/compute/vmware/vmware_vm.py | 2 +- gns3server/utils/asyncio/embed_shell.py | 4 +- gns3server/utils/asyncio/telnet_server.py | 646 ++++++++---------- requirements.txt | 1 + tests/utils/asyncio/test_telnet_server.py | 177 +++++ 9 files changed, 482 insertions(+), 363 deletions(-) create mode 100644 tests/utils/asyncio/test_telnet_server.py diff --git a/gns3server/compute/base_node.py b/gns3server/compute/base_node.py index 182e10f9b..020588e4f 100644 --- a/gns3server/compute/base_node.py +++ b/gns3server/compute/base_node.py @@ -397,11 +397,7 @@ class BaseNode: echo=True ) # warning: this will raise OSError exception if there is a problem... - self._wrapper_telnet_server = await asyncio.start_server( - server.run, - self._manager.port_manager.console_host, - self.console - ) + self._wrapper_telnet_server = await server.start(self._manager.port_manager.console_host, self.console) async def stop_wrap_console(self): """ diff --git a/gns3server/compute/docker/docker_vm.py b/gns3server/compute/docker/docker_vm.py index 79b5f640b..edfb46ee0 100644 --- a/gns3server/compute/docker/docker_vm.py +++ b/gns3server/compute/docker/docker_vm.py @@ -587,7 +587,7 @@ class DockerVM(BaseNode): raise DockerError("Could not start auxiliary console process: {}".format(e)) server = AsyncioTelnetServer(reader=process.stdout, writer=process.stdin, binary=True, echo=True) try: - self._telnet_servers.append((await asyncio.start_server(server.run, self._manager.port_manager.console_host, self.aux))) + self._telnet_servers.append(await server.start(self._manager.port_manager.console_host, self.aux)) except OSError as e: raise DockerError("Could not start Telnet server on socket {}:{}: {}".format(self._manager.port_manager.console_host, self.aux, e)) log.debug("Docker container '%s' started listen for auxiliary telnet on %d", self.name, self.aux) @@ -747,7 +747,7 @@ class DockerVM(BaseNode): input_stream = InputStream() telnet = AsyncioTelnetServer(reader=output_stream, writer=input_stream, echo=True, naws=True, window_size_changed_callback=self._window_size_changed_callback) try: - self._telnet_servers.append((await asyncio.start_server(telnet.run, self._manager.port_manager.console_host, self.console))) + self._telnet_servers.append(await telnet.start(self._manager.port_manager.console_host, self.console)) except OSError as e: raise DockerError("Could not start Telnet server on socket {}:{}: {}".format(self._manager.port_manager.console_host, self.console, e)) diff --git a/gns3server/compute/iou/iou_vm.py b/gns3server/compute/iou/iou_vm.py index 9089d8e68..c545472bc 100644 --- a/gns3server/compute/iou/iou_vm.py +++ b/gns3server/compute/iou/iou_vm.py @@ -598,8 +598,7 @@ class IOUVM(BaseNode): server = AsyncioTelnetServer(reader=self._iou_process.stdout, writer=self._iou_process.stdin, binary=True, echo=True) try: - self._telnet_server = await asyncio.start_server(server.run, self._manager.port_manager.console_host, - self.console) + self._telnet_server = await server.start(self._manager.port_manager.console_host, self.console) except OSError as e: await self.stop() raise IOUError( diff --git a/gns3server/compute/virtualbox/virtualbox_vm.py b/gns3server/compute/virtualbox/virtualbox_vm.py index 5273bfe1c..e3ec7f5c4 100644 --- a/gns3server/compute/virtualbox/virtualbox_vm.py +++ b/gns3server/compute/virtualbox/virtualbox_vm.py @@ -982,7 +982,7 @@ class VirtualBoxVM(BaseNode): binary=True, echo=True) try: - self._telnet_server = await asyncio.start_server(server.run, self._manager.port_manager.console_host, self.console) + self._telnet_server = await server.start(self._manager.port_manager.console_host, self.console) except OSError as e: self.project.emit("log.warning", {"message": "Could not start Telnet server on socket {}:{}: {}".format(self._manager.port_manager.console_host, self.console, e)}) diff --git a/gns3server/compute/vmware/vmware_vm.py b/gns3server/compute/vmware/vmware_vm.py index 52ebcea3d..9f2962998 100644 --- a/gns3server/compute/vmware/vmware_vm.py +++ b/gns3server/compute/vmware/vmware_vm.py @@ -881,7 +881,7 @@ class VMwareVM(BaseNode): binary=True, echo=True) try: - self._telnet_server = await asyncio.start_server(server.run, self._manager.port_manager.console_host, self.console) + self._telnet_server = await server.start(self._manager.port_manager.console_host, self.console) except OSError as e: self.project.emit("log.warning", {"message": "Could not start Telnet server on socket {}:{}: {}".format(self._manager.port_manager.console_host, self.console, e)}) diff --git a/gns3server/utils/asyncio/embed_shell.py b/gns3server/utils/asyncio/embed_shell.py index 05e2f5ea7..1d1577d02 100644 --- a/gns3server/utils/asyncio/embed_shell.py +++ b/gns3server/utils/asyncio/embed_shell.py @@ -352,12 +352,12 @@ if __name__ == '__main__': # Demo using telnet shell = Demo(welcome_message="Welcome!\n") server = create_telnet_shell(shell, loop=loop) - coro = asyncio.start_server(server.run, '127.0.0.1', 4444) - s = loop.run_until_complete(coro) + loop.run_until_complete(server.start("127.0.0.1", 4444)) try: loop.run_forever() except KeyboardInterrupt: pass + loop.run_until_complete(server.close()) # Demo using stdin # loop.run_until_complete(create_stdin_shell(Demo())) diff --git a/gns3server/utils/asyncio/telnet_server.py b/gns3server/utils/asyncio/telnet_server.py index cc34a29a6..8f571d1f1 100644 --- a/gns3server/utils/asyncio/telnet_server.py +++ b/gns3server/utils/asyncio/telnet_server.py @@ -1,4 +1,3 @@ -# -*- coding: utf-8 -*- # # Copyright (C) 2014 GNS3 Technologies Inc. # @@ -15,52 +14,46 @@ # You should have received a copy of the GNU General Public License # along with this program. If not, see . -import sys -import socket import asyncio import asyncio.subprocess -import struct - +import contextlib import logging +import socket + +import telnetlib3 +from telnetlib3.server import TelnetServer +from telnetlib3.telopt import DONT, ECHO, IAC, NAWS, WILL, WONT + log = logging.getLogger(__name__) -# Mostly from https://code.google.com/p/miniboa/source/browse/trunk/miniboa/telnet.py - -# Telnet Commands -SE = 240 # End of sub-negotiation parameters -NOP = 241 # No operation -DATMK = 242 # Data stream portion of a sync. -BREAK = 243 # NVT Character BRK -IP = 244 # Interrupt Process -AO = 245 # Abort Output -AYT = 246 # Are you there -EC = 247 # Erase Character -EL = 248 # Erase Line -GA = 249 # The Go Ahead Signal -SB = 250 # Sub-option to follow -WILL = 251 # Will; request or confirm option begin -WONT = 252 # Wont; deny option request -DO = 253 # Do = Request or confirm remote option -DONT = 254 # Don't = Demand or confirm option halt -IAC = 255 # Interpret as Command -SEND = 1 # Sub-process negotiation SEND command -IS = 0 # Sub-process negotiation IS command - -# Telnet Options -BINARY = 0 # Transmit Binary -ECHO = 1 # Echo characters back to sender -RECON = 2 # Reconnection -SGA = 3 # Suppress Go-Ahead -TMARK = 6 # Timing Mark -TTYPE = 24 # Terminal Type -NAWS = 31 # Negotiate About Window Size -LINEMO = 34 # Line Mode - READ_SIZE = 1024 +BROADCAST_DRAIN_TIMEOUT = 10 -class TelnetConnection(object): +class _ManagedTelnetListener: + """Compatibility wrapper that owns AsyncioTelnetServer shutdown.""" + + def __init__(self, telnet_server, listener): + self._telnet_server = telnet_server + self._listener = listener + self._close_task = None + + def close(self): + if self._close_task is None: + self._close_task = asyncio.create_task(self._telnet_server.close()) + + async def wait_closed(self): + self.close() + with contextlib.suppress(asyncio.CancelledError): + await self._close_task + + def __getattr__(self, attribute): + return getattr(self._listener, attribute) + + +class TelnetConnection: """Default implementation of telnet connection which may but may not be used.""" + def __init__(self, reader, writer, window_size_changed_callback=None): self.is_closing = False self._reader = reader @@ -85,60 +78,63 @@ class TelnetConnection(object): async def window_size_changed(self, columns, rows): """Method called when window size changed, only can occur when - `naws` flag is enable in server configuration.""" + `naws` flag is enabled in server configuration.""" if self._window_size_changed_callback: await self._window_size_changed_callback(columns, rows) async def feed(self, data): - """ - Handles incoming data - :return: - """ + """Handles incoming data.""" def send(self, data): - """ - Sending data back to client - :return: - """ + """Send data back to client.""" + data = data.decode().replace("\n", "\r\n") self.writer.write(data.encode()) def close(self): - """ - Closes current connection - :return: - """ + """Close current connection.""" + self.is_closing = True class AsyncioTelnetServer: - MAX_NEGOTIATION_READ = 10 + def __init__( + self, + reader=None, + writer=None, + binary=True, + echo=False, + naws=False, + window_size_changed_callback=None, + connection_factory=None, + ): + """ + Initialize telnet server. - def __init__(self, reader=None, writer=None, binary=True, echo=False, naws=False, window_size_changed_callback=None, connection_factory=None): + :param naws: when True, window size negotiation callbacks are enabled. + :param connection_factory: optional factory to inject a custom connection implementation. """ - Initializes telnet server - :param naws when True make a window size negotiation - :param connection_factory: when set it's possible to inject own implementation of connection - """ - assert connection_factory is None or (connection_factory is not None and reader is None and writer is None), \ - "Please use either reader and writer either connection_factory, otherwise duplicate data may be produced." + + assert connection_factory is None or ( + connection_factory is not None and reader is None and writer is None + ), "Please use either reader and writer either connection_factory, otherwise duplicate data may be produced." self._reader = reader self._writer = writer - self._connections = dict() - self._lock = asyncio.Lock() - self._reader_process = None - self._current_read = None self._window_size_changed_callback = window_size_changed_callback - self._binary = binary - # If echo is true when the client send data - # the data is echo on his terminal by telnet otherwise - # it's our job (or the wrapped app) to send back the data self._echo = echo self._naws = naws + self._connections = {} + self._pending_window_sizes = {} + self._connections_lock = asyncio.Lock() + self._close_lock = asyncio.Lock() + self._broadcast_task = None + self._server = None + self._server_handle = None + def default_connection_factory(reader, writer, window_size_changed_callback): return TelnetConnection(reader, writer, window_size_changed_callback) @@ -149,317 +145,267 @@ class AsyncioTelnetServer: @staticmethod async def write_client_intro(writer, echo=False): - # Send initial telnet session opening + """Write a minimal telnet intro to an upstream console endpoint.""" + if echo: - writer.write(bytes([IAC, WILL, ECHO])) + writer.write(IAC + WILL + ECHO) else: - writer.write(bytes([ - IAC, WONT, ECHO, - IAC, DONT, ECHO])) + writer.write(IAC + WONT + ECHO + IAC + DONT + ECHO) await writer.drain() - async def _write_intro(self, writer, binary=False, echo=False, naws=False): - # Send initial telnet session opening - if echo: - writer.write(bytes([IAC, WILL, ECHO])) - else: - writer.write(bytes([ - IAC, WONT, ECHO, - IAC, DONT, ECHO])) + async def start(self, host, port): + """Start a telnetlib3-backed listener and return a managed server handle.""" - if binary: - writer.write(bytes([ - IAC, WILL, SGA, - IAC, WILL, BINARY, - IAC, DO, BINARY])) - else: - writer.write(bytes([ - IAC, WONT, SGA, - IAC, DONT, SGA, - IAC, WONT, BINARY, - IAC, DONT, BINARY])) + if self._server is not None: + raise RuntimeError("AsyncioTelnetServer is already started") - if naws: - writer.write(bytes([ - IAC, DO, NAWS - ])) - await writer.drain() + protocol_factory = self._build_protocol_factory() + self._server = await telnetlib3.create_server( + host=host, + port=port, + protocol_factory=protocol_factory, + shell=self._run_client_session, + encoding=False, + force_binary=self._binary, + never_send_ga=True, + line_mode=not self._binary, + timeout=0, + connect_maxwait=1.0, + ) + self._server_handle = _ManagedTelnetListener(self, self._server) + + if self._reader is not None and self._broadcast_task is None: + self._broadcast_task = asyncio.create_task(self._broadcast_from_upstream()) + + return self._server_handle async def run(self, network_reader, network_writer): - sock = network_writer.get_extra_info("socket") - sock.setsockopt(socket.SOL_SOCKET, socket.SO_KEEPALIVE, 1) - sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1) - # 60 sec keep alives, close tcp session after 4 missed - # Will keep a firewall from aging out telnet console. + """Backward-compatible entrypoint for asyncio.start_server(server.run, ...).""" + + await self._run_client_session(network_reader, network_writer) + + async def close(self): + async with self._close_lock: + if self._broadcast_task is not None: + broadcast_task = self._broadcast_task + self._broadcast_task = None + broadcast_task.cancel() + with contextlib.suppress(asyncio.CancelledError): + await broadcast_task + else: + await self._disconnect_all_clients() + + if self._server is not None: + self._server.close() + await self._server.wait_closed() + self._server = None + + self._server_handle = None + + async def client_connected_hook(self): + pass + + def _build_protocol_factory(self): + parent = self + + class GNS3TelnetServer(TelnetServer): + def _negotiate_echo(self): + if self._echo_negotiated: + return + self._echo_negotiated = True + + if self.line_mode: + return + + if parent._echo: + self.writer.iac(WILL, ECHO) + else: + self.writer.iac(WONT, ECHO) + self.writer.iac(DONT, ECHO) + + def begin_advanced_negotiation(self): + super().begin_advanced_negotiation() + if not parent._naws: + self.writer.iac(DONT, NAWS) + + def on_naws(self, rows, cols): + super().on_naws(rows, cols) + parent._handle_naws(self.writer, cols, rows) + + return GNS3TelnetServer + + def _handle_naws(self, writer, columns, rows): + if not self._naws: + return + asyncio.create_task(self._dispatch_window_size(writer, columns, rows)) + + async def _dispatch_window_size(self, writer, columns, rows): + async with self._connections_lock: + connection = self._connections.get(writer) + if connection is None: + self._pending_window_sizes[writer] = (columns, rows) + return + + await self._invoke_window_size_changed(connection, columns, rows) + + async def _invoke_window_size_changed(self, connection, columns, rows): + try: + await connection.window_size_changed(columns, rows) + except (ConnectionError, OSError): + connection.close() + + async def _run_client_session(self, network_reader, network_writer): + self._set_socket_options(network_writer) + + connection = self._connection_factory(network_reader, network_writer, self._window_size_changed_callback) + + async with self._connections_lock: + self._connections[network_writer] = connection + pending_window_size = self._pending_window_sizes.pop(network_writer, None) + + if pending_window_size is not None: + columns, rows = pending_window_size + await self._invoke_window_size_changed(connection, columns, rows) + + try: + await connection.connected() + await self.client_connected_hook() + + while True: + data = await network_reader.read(READ_SIZE) + if not data: + break + + if not self._binary: + data = data.replace(b"\r\n", b"\n") + + if self._writer is not None: + self._writer.write(data) + await self._writer.drain() + + await connection.feed(data) + if connection.is_closing: + break + + except asyncio.CancelledError: + raise + except (ConnectionError, OSError): + pass + finally: + await self._disconnect_client(network_writer) + + async def _broadcast_from_upstream(self): + try: + while True: + data = await self._reader.read(READ_SIZE) + if not data: + break + + for network_writer, connection in await self._get_connections_snapshot(): + try: + connection.writer.write(data) + await asyncio.wait_for(connection.writer.drain(), timeout=BROADCAST_DRAIN_TIMEOUT) + except (OSError, ConnectionError, asyncio.TimeoutError) as e: + client_info = self._get_peername(network_writer) + log.debug( + "Error sending data to client %s: %s, closing and removing from connection table.", + client_info, + e, + ) + connection.close() + await self._disconnect_client(network_writer) + except asyncio.CancelledError: + raise + except (ConnectionError, OSError): + pass + finally: + await self._disconnect_all_clients() + + async def _get_connections_snapshot(self): + async with self._connections_lock: + return list(self._connections.items()) + + async def _disconnect_all_clients(self): + async with self._connections_lock: + writers = list(self._connections.keys()) + + for network_writer in writers: + await self._disconnect_client(network_writer) + + async def _disconnect_client(self, network_writer): + async with self._connections_lock: + connection = self._connections.pop(network_writer, None) + self._pending_window_sizes.pop(network_writer, None) + + if connection is not None: + with contextlib.suppress(Exception): + await connection.disconnected() + + with contextlib.suppress(AttributeError, OSError): + network_writer.close() + + wait_closed = getattr(network_writer, "wait_closed", None) + if callable(wait_closed): + with contextlib.suppress(ConnectionError, OSError): + await wait_closed() + + @staticmethod + def _get_peername(network_writer): + with contextlib.suppress(OSError, AttributeError): + sock = network_writer.get_extra_info("socket") + if sock is not None: + return sock.getpeername() + return network_writer.get_extra_info("peername") + + @staticmethod + def _set_socket_options(network_writer): + sock = network_writer.get_extra_info("socket") + if sock is None: + return + + with contextlib.suppress(OSError): + sock.setsockopt(socket.SOL_SOCKET, socket.SO_KEEPALIVE, 1) + sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1) + + # 60 sec keep alives, close tcp session after 4 missed. + # This keeps stateful firewalls from aging out long-lived sessions. try: - # Keepalive options are platform dependent: Linux uses TCP_KEEPIDLE, - # while macOS exposes TCP_KEEPALIVE (in Python >= 3.10). if hasattr(socket, "TCP_KEEPIDLE"): sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_KEEPIDLE, 60) elif hasattr(socket, "TCP_KEEPALIVE"): sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_KEEPALIVE, 60) else: - raise AttributeError("module 'socket' has no attribute 'TCP_KEEPIDLE' or 'TCP_KEEPALIVE'") + raise AttributeError("No TCP keepalive idle socket option is available") + sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_KEEPINTVL, 10) sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_KEEPCNT, 4) except (AttributeError, OSError): log.debug("Failed to tune TCP keepalive for telnet client; using OS defaults", exc_info=True) - #log.debug("New connection from {}".format(sock.getpeername())) - # Keep track of connected clients - connection = self._connection_factory(network_reader, network_writer, self._window_size_changed_callback) - self._connections[network_writer] = connection - - try: - await self._write_intro(network_writer, echo=self._echo, binary=self._binary, naws=self._naws) - await connection.connected() - await self._process(network_reader, network_writer, connection) - except (ConnectionError, OSError): - async with self._lock: - network_writer.close() - # await network_writer.wait_closed() # this doesn't work in Python 3.6 - if self._reader_process == network_reader: - self._reader_process = None - # Cancel current read from this reader - if self._current_read is not None: - self._current_read.cancel() - - await connection.disconnected() - del self._connections[network_writer] - - async def close(self): - for writer, connection in self._connections.items(): - try: - writer.write_eof() - await writer.drain() - writer.close() - # await writer.wait_closed() # this doesn't work in Python 3.6 - except (AttributeError, ConnectionError): - continue - - async def client_connected_hook(self): - pass - - async def _get_reader(self, network_reader): - """ - Get a reader or None if another reader is already reading. - """ - async with self._lock: - if self._reader_process is None: - self._reader_process = network_reader - if self._reader: - if self._reader_process == network_reader: - self._current_read = asyncio.ensure_future(self._reader.read(READ_SIZE)) - return self._current_read - return None - - async def _process(self, network_reader, network_writer, connection): - network_read = asyncio.ensure_future(network_reader.read(READ_SIZE)) - reader_read = await self._get_reader(network_reader) - - while True: - if reader_read is None: - reader_read = await self._get_reader(network_reader) - if reader_read is None: - done, pending = await asyncio.wait( - [ - network_read, - ], - timeout=1, - return_when=asyncio.FIRST_COMPLETED) - else: - done, pending = await asyncio.wait( - [ - network_read, - reader_read - ], - return_when=asyncio.FIRST_COMPLETED) - for coro in done: - data = coro.result() - if coro == network_read: - if network_reader.at_eof(): - raise ConnectionResetError() - - network_read = asyncio.ensure_future(network_reader.read(READ_SIZE)) - - if IAC in data: - data = await self._IAC_parser(data, network_reader, network_writer, connection) - - if len(data) == 0: - continue - - if not self._binary: - data = data.replace(b"\r\n", b"\n") - - if self._writer: - self._writer.write(data) - await self._writer.drain() - - await connection.feed(data) - if connection.is_closing: - raise ConnectionResetError() - - elif coro == reader_read: - if self._reader and self._reader.at_eof(): - raise ConnectionResetError() - - reader_read = await self._get_reader(network_reader) - - # Replicate the output on all clients - for connection_key in list(self._connections.keys()): - connection = self._connections[connection_key] - client_info = None - - try: - client_info = connection_key.get_extra_info("socket").getpeername() - connection.writer.write(data) - await asyncio.wait_for(connection.writer.drain(), timeout=10) - except (OSError, ConnectionError, asyncio.TimeoutError) as e: - log.debug(f"Error sending data to client {client_info}: {e}, closing and removing from connection table.") - connection.close() - del self._connections[connection_key] - - async def _read(self, cmd, buffer, location, reader): - """ Reads next op from the buffer or reader""" - try: - op = buffer[location] - cmd.append(op) - return op - except IndexError: - op = await reader.read(1) - buffer.extend(op) - cmd.append(buffer[location]) - return op - - async def _negotiate(self, data, connection): - """ Performs negotiation commands""" - - command, payload = data[0], data[1:] - if command == NAWS: - if len(payload) == 4: - columns, rows = struct.unpack(str('!HH'), bytes(payload)) - await connection.window_size_changed(columns, rows) - else: - log.warning('Wrong number of NAWS bytes') - else: - log.debug("Not supported negotiation sequence, received {} bytes", len(data)) - - async def _IAC_parser(self, buf, network_reader, network_writer, connection): - """ - Processes and removes any Telnet commands from the buffer. - - :param buf: buffer - :returns: buffer minus Telnet commands - """ - - - skip_to = 0 - while True: - # Locate an IAC to process - iac_loc = buf.find(IAC, skip_to) - if iac_loc < 0: - break - - # Get the TELNET command - iac_cmd = bytearray([IAC]) - try: - iac_cmd.append(buf[iac_loc + 1]) - except IndexError: - d = await network_reader.read(1) - buf.extend(d) - iac_cmd.append(buf[iac_loc + 1]) - - # Is this just a 2-byte TELNET command? - if iac_cmd[1] not in [WILL, WONT, DO, DONT, SB]: - if iac_cmd[1] == AYT: - log.debug("Telnet server received Are-You-There (AYT)") - network_writer.write(b'\r\nYour Are-You-There received. I am here.\r\n') - elif iac_cmd[1] == IAC: - # It's data, not an IAC - iac_cmd.pop() - # This prevents the 0xff from being - # interrupted as yet another IAC - skip_to = iac_loc + 1 - log.debug("Received IAC IAC") - elif iac_cmd[1] == NOP: - pass - else: - log.debug("Unhandled telnet command: " - "{0:#x} {1:#x}".format(*iac_cmd)) - elif iac_cmd[1] == SB: # starts negotiation commands - negotiation = [] - for pos in range(2, self.MAX_NEGOTIATION_READ): - op = await self._read(iac_cmd, buf, iac_loc + pos, network_reader) - negotiation.append(op) - if op == SE: - # ends negotiation commands - break - - # SE command is followed by IAC, remove the last two operations from stack - await self._negotiate(negotiation[0:-2], connection) - - # This must be a 3-byte TELNET command - else: - try: - iac_cmd.append(buf[iac_loc + 2]) - except IndexError: - d = await network_reader.read(1) - buf.extend(d) - iac_cmd.append(buf[iac_loc + 2]) - # We do ECHO, SGA, and BINARY. Period. - if iac_cmd[1] == DO: - if iac_cmd[2] not in [ECHO, SGA, BINARY]: - network_writer.write(bytes([IAC, WONT, iac_cmd[2]])) - log.debug("Telnet WON'T {:#x}".format(iac_cmd[2])) - else: - if iac_cmd[2] == SGA: - if self._binary: - network_writer.write(bytes([IAC, WILL, iac_cmd[2]])) - else: - network_writer.write(bytes([IAC, WONT, iac_cmd[2]])) - log.debug("Telnet WON'T {:#x}".format(iac_cmd[2])) - - elif iac_cmd[1] == DONT: - log.debug("Unhandled DONT telnet command: " - "{0:#x} {1:#x} {2:#x}".format(*iac_cmd)) - elif iac_cmd[1] == WILL: - if iac_cmd[2] not in [BINARY, NAWS]: - log.debug("Unhandled WILL telnet command: " - "{0:#x} {1:#x} {2:#x}".format(*iac_cmd)) - elif iac_cmd[1] == WONT: - log.debug("Unhandled WONT telnet command: " - "{0:#x} {1:#x} {2:#x}".format(*iac_cmd)) - else: - log.debug("Unhandled telnet command: " - "{0:#x} {1:#x} {2:#x}".format(*iac_cmd)) - - # Remove the entire TELNET command from the buffer - buf = buf.replace(iac_cmd, b'', 1) - - await network_writer.drain() - - # Return the new copy of the buffer, minus telnet commands - return buf - -if __name__ == '__main__': +if __name__ == "__main__": logging.basicConfig(level=logging.DEBUG) loop = asyncio.get_event_loop() - process = loop.run_until_complete(asyncio.ensure_future(asyncio.subprocess.create_subprocess_exec("/bin/sh", "-i", - stdout=asyncio.subprocess.PIPE, - stderr=asyncio.subprocess.STDOUT, - stdin=asyncio.subprocess.PIPE))) + process = loop.run_until_complete( + asyncio.ensure_future( + asyncio.subprocess.create_subprocess_exec( + "/bin/sh", + "-i", + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.STDOUT, + stdin=asyncio.subprocess.PIPE, + ) + ) + ) server = AsyncioTelnetServer(reader=process.stdout, writer=process.stdin, binary=False, echo=False) - coro = asyncio.start_server(server.run, '127.0.0.1', 4444) - s = loop.run_until_complete(coro) + loop.run_until_complete(server.start("127.0.0.1", 4444)) try: loop.run_forever() except KeyboardInterrupt: pass - # Close the server - s.close() - loop.run_until_complete(s.wait_closed()) + + loop.run_until_complete(server.close()) loop.close() diff --git a/requirements.txt b/requirements.txt index 9c322ec53..e337e4723 100644 --- a/requirements.txt +++ b/requirements.txt @@ -10,3 +10,4 @@ distro>=1.9.0 py-cpuinfo>=9.0.0,<10.0 platformdirs>=2.4.0,<3 # platformdirs >=3 conflicts when building Debian packages truststore>=0.10.4; python_version >= '3.10' +telnetlib3==4.0.1 diff --git a/tests/utils/asyncio/test_telnet_server.py b/tests/utils/asyncio/test_telnet_server.py new file mode 100644 index 000000000..dcd1c1efe --- /dev/null +++ b/tests/utils/asyncio/test_telnet_server.py @@ -0,0 +1,177 @@ +import asyncio +import contextlib + +import pytest +import telnetlib3 + +from gns3server.utils.asyncio.telnet_server import AsyncioTelnetServer + + +class DummyUpstreamWriter: + def __init__(self): + self.buffer = bytearray() + self.write_event = asyncio.Event() + + def write(self, data): + self.buffer.extend(data) + self.write_event.set() + + async def drain(self): + await asyncio.sleep(0) + + +def _get_listen_port(server): + return server.sockets[0].getsockname()[1] + + +async def _wait_for_connection_count(server, expected_count, timeout=2): + deadline = asyncio.get_running_loop().time() + timeout + last_count = 0 + while asyncio.get_running_loop().time() < deadline: + async with server._connections_lock: + last_count = len(server._connections) + if last_count == expected_count: + return + await asyncio.sleep(0.05) + assert last_count == expected_count + + +@pytest.mark.asyncio +async def test_telnet_server_forwards_client_input_to_upstream_writer(): + upstream_reader = asyncio.StreamReader() + upstream_writer = DummyUpstreamWriter() + server = AsyncioTelnetServer(reader=upstream_reader, writer=upstream_writer, binary=True, echo=True) + listener = await server.start("127.0.0.1", 0) + port = _get_listen_port(listener) + + client_writer = None + try: + _, client_writer = await telnetlib3.open_connection("127.0.0.1", port, encoding=False) + await _wait_for_connection_count(server, 1) + client_writer.write(b"ping\r\n") + await client_writer.drain() + + await asyncio.wait_for(upstream_writer.write_event.wait(), timeout=2) + assert b"ping\r\n" in bytes(upstream_writer.buffer) + finally: + if client_writer is not None: + client_writer.close() + listener.close() + await listener.wait_closed() + + +@pytest.mark.asyncio +async def test_telnet_server_broadcasts_upstream_output_to_all_clients(): + upstream_reader = asyncio.StreamReader() + upstream_writer = DummyUpstreamWriter() + server = AsyncioTelnetServer(reader=upstream_reader, writer=upstream_writer, binary=True, echo=True) + listener = await server.start("127.0.0.1", 0) + port = _get_listen_port(listener) + + client1_reader = client1_writer = None + client2_reader = client2_writer = None + + try: + client1_reader, client1_writer = await telnetlib3.open_connection("127.0.0.1", port, encoding=False) + client2_reader, client2_writer = await telnetlib3.open_connection("127.0.0.1", port, encoding=False) + await _wait_for_connection_count(server, 2) + + upstream_reader.feed_data(b"hello") + + data1 = await asyncio.wait_for(client1_reader.read(5), timeout=2) + data2 = await asyncio.wait_for(client2_reader.read(5), timeout=2) + + assert data1 == b"hello" + assert data2 == b"hello" + finally: + if client1_writer is not None: + client1_writer.close() + if client2_writer is not None: + client2_writer.close() + listener.close() + await listener.wait_closed() + + +@pytest.mark.asyncio +async def test_telnet_server_naws_callback_uses_negotiated_dimensions(): + callback_event = asyncio.Event() + callback_result = {} + + async def window_size_changed(columns, rows): + callback_result["columns"] = columns + callback_result["rows"] = rows + callback_event.set() + + server = AsyncioTelnetServer( + binary=True, + echo=True, + naws=True, + window_size_changed_callback=window_size_changed, + ) + listener = await server.start("127.0.0.1", 0) + port = _get_listen_port(listener) + + client_writer = None + try: + _, client_writer = await telnetlib3.open_connection( + "127.0.0.1", port, encoding=False, cols=132, rows=44 + ) + + await asyncio.wait_for(callback_event.wait(), timeout=2) + assert callback_result == {"columns": 132, "rows": 44} + finally: + if client_writer is not None: + client_writer.close() + listener.close() + await listener.wait_closed() + + +@pytest.mark.asyncio +async def test_telnet_server_broadcast_survives_client_disconnect(): + upstream_reader = asyncio.StreamReader() + upstream_writer = DummyUpstreamWriter() + server = AsyncioTelnetServer(reader=upstream_reader, writer=upstream_writer, binary=True, echo=True) + listener = await server.start("127.0.0.1", 0) + port = _get_listen_port(listener) + + client1_writer = None + client2_reader = client2_writer = None + + try: + _, client1_writer = await telnetlib3.open_connection("127.0.0.1", port, encoding=False) + client2_reader, client2_writer = await telnetlib3.open_connection("127.0.0.1", port, encoding=False) + await _wait_for_connection_count(server, 2) + + client1_writer.close() + await asyncio.sleep(0.2) + + upstream_reader.feed_data(b"ok") + data2 = await asyncio.wait_for(client2_reader.read(2), timeout=2) + + assert data2 == b"ok" + async with server._connections_lock: + assert len(server._connections) <= 1 + finally: + if client1_writer is not None: + with contextlib.suppress(Exception): + client1_writer.close() + if client2_writer is not None: + with contextlib.suppress(Exception): + client2_writer.close() + listener.close() + await listener.wait_closed() + + +@pytest.mark.asyncio +async def test_telnet_server_listener_close_cleans_internal_tasks(): + upstream_reader = asyncio.StreamReader() + upstream_writer = DummyUpstreamWriter() + server = AsyncioTelnetServer(reader=upstream_reader, writer=upstream_writer, binary=True, echo=True) + listener = await server.start("127.0.0.1", 0) + + assert server._broadcast_task is not None + + listener.close() + await listener.wait_closed() + + assert server._broadcast_task is None From c739a16d703471c75730684364584e0767de4ff2 Mon Sep 17 00:00:00 2001 From: Cristi Date: Tue, 24 Mar 2026 15:23:00 +0200 Subject: [PATCH 10/12] Improved exception handling and telnet client disconnect --- gns3server/utils/asyncio/telnet_server.py | 4 ++++ tests/utils/asyncio/test_telnet_server.py | 2 +- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/gns3server/utils/asyncio/telnet_server.py b/gns3server/utils/asyncio/telnet_server.py index 8f571d1f1..a6b54d706 100644 --- a/gns3server/utils/asyncio/telnet_server.py +++ b/gns3server/utils/asyncio/telnet_server.py @@ -251,8 +251,12 @@ class AsyncioTelnetServer: async def _invoke_window_size_changed(self, connection, columns, rows): try: await connection.window_size_changed(columns, rows) + except asyncio.CancelledError: + raise except (ConnectionError, OSError): connection.close() + except Exception: + log.exception("Unhandled exception in window_size_changed callback for %r", connection) async def _run_client_session(self, network_reader, network_writer): self._set_socket_options(network_writer) diff --git a/tests/utils/asyncio/test_telnet_server.py b/tests/utils/asyncio/test_telnet_server.py index dcd1c1efe..d91fb91c9 100644 --- a/tests/utils/asyncio/test_telnet_server.py +++ b/tests/utils/asyncio/test_telnet_server.py @@ -143,7 +143,7 @@ async def test_telnet_server_broadcast_survives_client_disconnect(): await _wait_for_connection_count(server, 2) client1_writer.close() - await asyncio.sleep(0.2) + await _wait_for_connection_count(server, 1) upstream_reader.feed_data(b"ok") data2 = await asyncio.wait_for(client2_reader.read(2), timeout=2) From bc6374f7fc148bb7598cf12d8c3c682a1b3ddbb2 Mon Sep 17 00:00:00 2001 From: grossmj Date: Tue, 31 Mar 2026 18:30:59 +0800 Subject: [PATCH 11/12] Add keepalive mechanism using the NOP command --- gns3server/utils/asyncio/telnet_server.py | 40 ++++++++++++++++++++++- 1 file changed, 39 insertions(+), 1 deletion(-) diff --git a/gns3server/utils/asyncio/telnet_server.py b/gns3server/utils/asyncio/telnet_server.py index a6b54d706..cf4a61870 100644 --- a/gns3server/utils/asyncio/telnet_server.py +++ b/gns3server/utils/asyncio/telnet_server.py @@ -22,12 +22,13 @@ import socket import telnetlib3 from telnetlib3.server import TelnetServer -from telnetlib3.telopt import DONT, ECHO, IAC, NAWS, WILL, WONT +from telnetlib3.telopt import DONT, ECHO, IAC, NAWS, NOP, WILL, WONT log = logging.getLogger(__name__) READ_SIZE = 1024 BROADCAST_DRAIN_TIMEOUT = 10 +KEEPALIVE_INTERVAL = 60 # Send NOP every 60 seconds class _ManagedTelnetListener: @@ -108,12 +109,14 @@ class AsyncioTelnetServer: naws=False, window_size_changed_callback=None, connection_factory=None, + keepalive_interval=KEEPALIVE_INTERVAL, ): """ Initialize telnet server. :param naws: when True, window size negotiation callbacks are enabled. :param connection_factory: optional factory to inject a custom connection implementation. + :param keepalive_interval: interval in seconds for sending NOP keep-alive (0 to disable). """ assert connection_factory is None or ( @@ -126,12 +129,14 @@ class AsyncioTelnetServer: self._binary = binary self._echo = echo self._naws = naws + self._keepalive_interval = keepalive_interval self._connections = {} self._pending_window_sizes = {} self._connections_lock = asyncio.Lock() self._close_lock = asyncio.Lock() self._broadcast_task = None + self._keepalive_task = None self._server = None self._server_handle = None @@ -177,6 +182,9 @@ class AsyncioTelnetServer: if self._reader is not None and self._broadcast_task is None: self._broadcast_task = asyncio.create_task(self._broadcast_from_upstream()) + if self._keepalive_interval > 0 and self._keepalive_task is None: + self._keepalive_task = asyncio.create_task(self._send_keepalives()) + return self._server_handle async def run(self, network_reader, network_writer): @@ -186,6 +194,13 @@ class AsyncioTelnetServer: async def close(self): async with self._close_lock: + if self._keepalive_task is not None: + keepalive_task = self._keepalive_task + self._keepalive_task = None + keepalive_task.cancel() + with contextlib.suppress(asyncio.CancelledError): + await keepalive_task + if self._broadcast_task is not None: broadcast_task = self._broadcast_task self._broadcast_task = None @@ -325,6 +340,29 @@ class AsyncioTelnetServer: finally: await self._disconnect_all_clients() + async def _send_keepalives(self): + """Periodically send IAC NOP to all connected clients to keep sessions alive.""" + + try: + while True: + await asyncio.sleep(self._keepalive_interval) + for network_writer, connection in await self._get_connections_snapshot(): + client_info = self._get_peername(network_writer) + try: + log.debug("Sending keepalive to client %s", client_info) + connection.writer.send_iac(IAC + NOP) + await asyncio.wait_for(connection.writer.drain(), timeout=BROADCAST_DRAIN_TIMEOUT) + except (OSError, ConnectionError, asyncio.TimeoutError) as e: + log.debug( + "Keepalive failed for client %s: %s, closing connection.", + client_info, + e, + ) + connection.close() + await self._disconnect_client(network_writer) + except asyncio.CancelledError: + raise + async def _get_connections_snapshot(self): async with self._connections_lock: return list(self._connections.items()) From b222fba42ce73f5303c54b528d0cb6cac81e8237 Mon Sep 17 00:00:00 2001 From: Jeremy Grossmann Date: Sun, 5 Apr 2026 21:58:21 +0800 Subject: [PATCH 12/12] Revert "Backport telnetlib3 changes" --- gns3server/compute/base_node.py | 6 +- gns3server/compute/docker/docker_vm.py | 4 +- gns3server/compute/iou/iou_vm.py | 3 +- .../compute/virtualbox/virtualbox_vm.py | 2 +- gns3server/compute/vmware/vmware_vm.py | 2 +- gns3server/utils/asyncio/embed_shell.py | 4 +- gns3server/utils/asyncio/telnet_server.py | 686 +++++++++--------- requirements.txt | 1 - tests/utils/asyncio/test_telnet_server.py | 177 ----- 9 files changed, 362 insertions(+), 523 deletions(-) delete mode 100644 tests/utils/asyncio/test_telnet_server.py diff --git a/gns3server/compute/base_node.py b/gns3server/compute/base_node.py index 020588e4f..182e10f9b 100644 --- a/gns3server/compute/base_node.py +++ b/gns3server/compute/base_node.py @@ -397,7 +397,11 @@ class BaseNode: echo=True ) # warning: this will raise OSError exception if there is a problem... - self._wrapper_telnet_server = await server.start(self._manager.port_manager.console_host, self.console) + self._wrapper_telnet_server = await asyncio.start_server( + server.run, + self._manager.port_manager.console_host, + self.console + ) async def stop_wrap_console(self): """ diff --git a/gns3server/compute/docker/docker_vm.py b/gns3server/compute/docker/docker_vm.py index edfb46ee0..79b5f640b 100644 --- a/gns3server/compute/docker/docker_vm.py +++ b/gns3server/compute/docker/docker_vm.py @@ -587,7 +587,7 @@ class DockerVM(BaseNode): raise DockerError("Could not start auxiliary console process: {}".format(e)) server = AsyncioTelnetServer(reader=process.stdout, writer=process.stdin, binary=True, echo=True) try: - self._telnet_servers.append(await server.start(self._manager.port_manager.console_host, self.aux)) + self._telnet_servers.append((await asyncio.start_server(server.run, self._manager.port_manager.console_host, self.aux))) except OSError as e: raise DockerError("Could not start Telnet server on socket {}:{}: {}".format(self._manager.port_manager.console_host, self.aux, e)) log.debug("Docker container '%s' started listen for auxiliary telnet on %d", self.name, self.aux) @@ -747,7 +747,7 @@ class DockerVM(BaseNode): input_stream = InputStream() telnet = AsyncioTelnetServer(reader=output_stream, writer=input_stream, echo=True, naws=True, window_size_changed_callback=self._window_size_changed_callback) try: - self._telnet_servers.append(await telnet.start(self._manager.port_manager.console_host, self.console)) + self._telnet_servers.append((await asyncio.start_server(telnet.run, self._manager.port_manager.console_host, self.console))) except OSError as e: raise DockerError("Could not start Telnet server on socket {}:{}: {}".format(self._manager.port_manager.console_host, self.console, e)) diff --git a/gns3server/compute/iou/iou_vm.py b/gns3server/compute/iou/iou_vm.py index c545472bc..9089d8e68 100644 --- a/gns3server/compute/iou/iou_vm.py +++ b/gns3server/compute/iou/iou_vm.py @@ -598,7 +598,8 @@ class IOUVM(BaseNode): server = AsyncioTelnetServer(reader=self._iou_process.stdout, writer=self._iou_process.stdin, binary=True, echo=True) try: - self._telnet_server = await server.start(self._manager.port_manager.console_host, self.console) + self._telnet_server = await asyncio.start_server(server.run, self._manager.port_manager.console_host, + self.console) except OSError as e: await self.stop() raise IOUError( diff --git a/gns3server/compute/virtualbox/virtualbox_vm.py b/gns3server/compute/virtualbox/virtualbox_vm.py index e3ec7f5c4..5273bfe1c 100644 --- a/gns3server/compute/virtualbox/virtualbox_vm.py +++ b/gns3server/compute/virtualbox/virtualbox_vm.py @@ -982,7 +982,7 @@ class VirtualBoxVM(BaseNode): binary=True, echo=True) try: - self._telnet_server = await server.start(self._manager.port_manager.console_host, self.console) + self._telnet_server = await asyncio.start_server(server.run, self._manager.port_manager.console_host, self.console) except OSError as e: self.project.emit("log.warning", {"message": "Could not start Telnet server on socket {}:{}: {}".format(self._manager.port_manager.console_host, self.console, e)}) diff --git a/gns3server/compute/vmware/vmware_vm.py b/gns3server/compute/vmware/vmware_vm.py index 9f2962998..52ebcea3d 100644 --- a/gns3server/compute/vmware/vmware_vm.py +++ b/gns3server/compute/vmware/vmware_vm.py @@ -881,7 +881,7 @@ class VMwareVM(BaseNode): binary=True, echo=True) try: - self._telnet_server = await server.start(self._manager.port_manager.console_host, self.console) + self._telnet_server = await asyncio.start_server(server.run, self._manager.port_manager.console_host, self.console) except OSError as e: self.project.emit("log.warning", {"message": "Could not start Telnet server on socket {}:{}: {}".format(self._manager.port_manager.console_host, self.console, e)}) diff --git a/gns3server/utils/asyncio/embed_shell.py b/gns3server/utils/asyncio/embed_shell.py index 1d1577d02..05e2f5ea7 100644 --- a/gns3server/utils/asyncio/embed_shell.py +++ b/gns3server/utils/asyncio/embed_shell.py @@ -352,12 +352,12 @@ if __name__ == '__main__': # Demo using telnet shell = Demo(welcome_message="Welcome!\n") server = create_telnet_shell(shell, loop=loop) - loop.run_until_complete(server.start("127.0.0.1", 4444)) + coro = asyncio.start_server(server.run, '127.0.0.1', 4444) + s = loop.run_until_complete(coro) try: loop.run_forever() except KeyboardInterrupt: pass - loop.run_until_complete(server.close()) # Demo using stdin # loop.run_until_complete(create_stdin_shell(Demo())) diff --git a/gns3server/utils/asyncio/telnet_server.py b/gns3server/utils/asyncio/telnet_server.py index cf4a61870..cc34a29a6 100644 --- a/gns3server/utils/asyncio/telnet_server.py +++ b/gns3server/utils/asyncio/telnet_server.py @@ -1,3 +1,4 @@ +# -*- coding: utf-8 -*- # # Copyright (C) 2014 GNS3 Technologies Inc. # @@ -14,47 +15,52 @@ # You should have received a copy of the GNU General Public License # along with this program. If not, see . +import sys +import socket import asyncio import asyncio.subprocess -import contextlib +import struct + import logging -import socket - -import telnetlib3 -from telnetlib3.server import TelnetServer -from telnetlib3.telopt import DONT, ECHO, IAC, NAWS, NOP, WILL, WONT - log = logging.getLogger(__name__) +# Mostly from https://code.google.com/p/miniboa/source/browse/trunk/miniboa/telnet.py + +# Telnet Commands +SE = 240 # End of sub-negotiation parameters +NOP = 241 # No operation +DATMK = 242 # Data stream portion of a sync. +BREAK = 243 # NVT Character BRK +IP = 244 # Interrupt Process +AO = 245 # Abort Output +AYT = 246 # Are you there +EC = 247 # Erase Character +EL = 248 # Erase Line +GA = 249 # The Go Ahead Signal +SB = 250 # Sub-option to follow +WILL = 251 # Will; request or confirm option begin +WONT = 252 # Wont; deny option request +DO = 253 # Do = Request or confirm remote option +DONT = 254 # Don't = Demand or confirm option halt +IAC = 255 # Interpret as Command +SEND = 1 # Sub-process negotiation SEND command +IS = 0 # Sub-process negotiation IS command + +# Telnet Options +BINARY = 0 # Transmit Binary +ECHO = 1 # Echo characters back to sender +RECON = 2 # Reconnection +SGA = 3 # Suppress Go-Ahead +TMARK = 6 # Timing Mark +TTYPE = 24 # Terminal Type +NAWS = 31 # Negotiate About Window Size +LINEMO = 34 # Line Mode + READ_SIZE = 1024 -BROADCAST_DRAIN_TIMEOUT = 10 -KEEPALIVE_INTERVAL = 60 # Send NOP every 60 seconds -class _ManagedTelnetListener: - """Compatibility wrapper that owns AsyncioTelnetServer shutdown.""" - - def __init__(self, telnet_server, listener): - self._telnet_server = telnet_server - self._listener = listener - self._close_task = None - - def close(self): - if self._close_task is None: - self._close_task = asyncio.create_task(self._telnet_server.close()) - - async def wait_closed(self): - self.close() - with contextlib.suppress(asyncio.CancelledError): - await self._close_task - - def __getattr__(self, attribute): - return getattr(self._listener, attribute) - - -class TelnetConnection: +class TelnetConnection(object): """Default implementation of telnet connection which may but may not be used.""" - def __init__(self, reader, writer, window_size_changed_callback=None): self.is_closing = False self._reader = reader @@ -79,66 +85,59 @@ class TelnetConnection: async def window_size_changed(self, columns, rows): """Method called when window size changed, only can occur when - `naws` flag is enabled in server configuration.""" + `naws` flag is enable in server configuration.""" if self._window_size_changed_callback: await self._window_size_changed_callback(columns, rows) async def feed(self, data): - """Handles incoming data.""" + """ + Handles incoming data + :return: + """ def send(self, data): - """Send data back to client.""" - + """ + Sending data back to client + :return: + """ data = data.decode().replace("\n", "\r\n") self.writer.write(data.encode()) def close(self): - """Close current connection.""" - + """ + Closes current connection + :return: + """ self.is_closing = True class AsyncioTelnetServer: - def __init__( - self, - reader=None, - writer=None, - binary=True, - echo=False, - naws=False, - window_size_changed_callback=None, - connection_factory=None, - keepalive_interval=KEEPALIVE_INTERVAL, - ): - """ - Initialize telnet server. + MAX_NEGOTIATION_READ = 10 - :param naws: when True, window size negotiation callbacks are enabled. - :param connection_factory: optional factory to inject a custom connection implementation. - :param keepalive_interval: interval in seconds for sending NOP keep-alive (0 to disable). + def __init__(self, reader=None, writer=None, binary=True, echo=False, naws=False, window_size_changed_callback=None, connection_factory=None): """ - - assert connection_factory is None or ( - connection_factory is not None and reader is None and writer is None - ), "Please use either reader and writer either connection_factory, otherwise duplicate data may be produced." + Initializes telnet server + :param naws when True make a window size negotiation + :param connection_factory: when set it's possible to inject own implementation of connection + """ + assert connection_factory is None or (connection_factory is not None and reader is None and writer is None), \ + "Please use either reader and writer either connection_factory, otherwise duplicate data may be produced." self._reader = reader self._writer = writer + self._connections = dict() + self._lock = asyncio.Lock() + self._reader_process = None + self._current_read = None self._window_size_changed_callback = window_size_changed_callback + self._binary = binary + # If echo is true when the client send data + # the data is echo on his terminal by telnet otherwise + # it's our job (or the wrapped app) to send back the data self._echo = echo self._naws = naws - self._keepalive_interval = keepalive_interval - - self._connections = {} - self._pending_window_sizes = {} - self._connections_lock = asyncio.Lock() - self._close_lock = asyncio.Lock() - self._broadcast_task = None - self._keepalive_task = None - self._server = None - self._server_handle = None def default_connection_factory(reader, writer, window_size_changed_callback): return TelnetConnection(reader, writer, window_size_changed_callback) @@ -150,304 +149,317 @@ class AsyncioTelnetServer: @staticmethod async def write_client_intro(writer, echo=False): - """Write a minimal telnet intro to an upstream console endpoint.""" - + # Send initial telnet session opening if echo: - writer.write(IAC + WILL + ECHO) + writer.write(bytes([IAC, WILL, ECHO])) else: - writer.write(IAC + WONT + ECHO + IAC + DONT + ECHO) + writer.write(bytes([ + IAC, WONT, ECHO, + IAC, DONT, ECHO])) await writer.drain() - async def start(self, host, port): - """Start a telnetlib3-backed listener and return a managed server handle.""" + async def _write_intro(self, writer, binary=False, echo=False, naws=False): + # Send initial telnet session opening + if echo: + writer.write(bytes([IAC, WILL, ECHO])) + else: + writer.write(bytes([ + IAC, WONT, ECHO, + IAC, DONT, ECHO])) - if self._server is not None: - raise RuntimeError("AsyncioTelnetServer is already started") + if binary: + writer.write(bytes([ + IAC, WILL, SGA, + IAC, WILL, BINARY, + IAC, DO, BINARY])) + else: + writer.write(bytes([ + IAC, WONT, SGA, + IAC, DONT, SGA, + IAC, WONT, BINARY, + IAC, DONT, BINARY])) - protocol_factory = self._build_protocol_factory() - self._server = await telnetlib3.create_server( - host=host, - port=port, - protocol_factory=protocol_factory, - shell=self._run_client_session, - encoding=False, - force_binary=self._binary, - never_send_ga=True, - line_mode=not self._binary, - timeout=0, - connect_maxwait=1.0, - ) - self._server_handle = _ManagedTelnetListener(self, self._server) - - if self._reader is not None and self._broadcast_task is None: - self._broadcast_task = asyncio.create_task(self._broadcast_from_upstream()) - - if self._keepalive_interval > 0 and self._keepalive_task is None: - self._keepalive_task = asyncio.create_task(self._send_keepalives()) - - return self._server_handle + if naws: + writer.write(bytes([ + IAC, DO, NAWS + ])) + await writer.drain() async def run(self, network_reader, network_writer): - """Backward-compatible entrypoint for asyncio.start_server(server.run, ...).""" - - await self._run_client_session(network_reader, network_writer) - - async def close(self): - async with self._close_lock: - if self._keepalive_task is not None: - keepalive_task = self._keepalive_task - self._keepalive_task = None - keepalive_task.cancel() - with contextlib.suppress(asyncio.CancelledError): - await keepalive_task - - if self._broadcast_task is not None: - broadcast_task = self._broadcast_task - self._broadcast_task = None - broadcast_task.cancel() - with contextlib.suppress(asyncio.CancelledError): - await broadcast_task - else: - await self._disconnect_all_clients() - - if self._server is not None: - self._server.close() - await self._server.wait_closed() - self._server = None - - self._server_handle = None - - async def client_connected_hook(self): - pass - - def _build_protocol_factory(self): - parent = self - - class GNS3TelnetServer(TelnetServer): - def _negotiate_echo(self): - if self._echo_negotiated: - return - self._echo_negotiated = True - - if self.line_mode: - return - - if parent._echo: - self.writer.iac(WILL, ECHO) - else: - self.writer.iac(WONT, ECHO) - self.writer.iac(DONT, ECHO) - - def begin_advanced_negotiation(self): - super().begin_advanced_negotiation() - if not parent._naws: - self.writer.iac(DONT, NAWS) - - def on_naws(self, rows, cols): - super().on_naws(rows, cols) - parent._handle_naws(self.writer, cols, rows) - - return GNS3TelnetServer - - def _handle_naws(self, writer, columns, rows): - if not self._naws: - return - asyncio.create_task(self._dispatch_window_size(writer, columns, rows)) - - async def _dispatch_window_size(self, writer, columns, rows): - async with self._connections_lock: - connection = self._connections.get(writer) - if connection is None: - self._pending_window_sizes[writer] = (columns, rows) - return - - await self._invoke_window_size_changed(connection, columns, rows) - - async def _invoke_window_size_changed(self, connection, columns, rows): - try: - await connection.window_size_changed(columns, rows) - except asyncio.CancelledError: - raise - except (ConnectionError, OSError): - connection.close() - except Exception: - log.exception("Unhandled exception in window_size_changed callback for %r", connection) - - async def _run_client_session(self, network_reader, network_writer): - self._set_socket_options(network_writer) - - connection = self._connection_factory(network_reader, network_writer, self._window_size_changed_callback) - - async with self._connections_lock: - self._connections[network_writer] = connection - pending_window_size = self._pending_window_sizes.pop(network_writer, None) - - if pending_window_size is not None: - columns, rows = pending_window_size - await self._invoke_window_size_changed(connection, columns, rows) - - try: - await connection.connected() - await self.client_connected_hook() - - while True: - data = await network_reader.read(READ_SIZE) - if not data: - break - - if not self._binary: - data = data.replace(b"\r\n", b"\n") - - if self._writer is not None: - self._writer.write(data) - await self._writer.drain() - - await connection.feed(data) - if connection.is_closing: - break - - except asyncio.CancelledError: - raise - except (ConnectionError, OSError): - pass - finally: - await self._disconnect_client(network_writer) - - async def _broadcast_from_upstream(self): - try: - while True: - data = await self._reader.read(READ_SIZE) - if not data: - break - - for network_writer, connection in await self._get_connections_snapshot(): - try: - connection.writer.write(data) - await asyncio.wait_for(connection.writer.drain(), timeout=BROADCAST_DRAIN_TIMEOUT) - except (OSError, ConnectionError, asyncio.TimeoutError) as e: - client_info = self._get_peername(network_writer) - log.debug( - "Error sending data to client %s: %s, closing and removing from connection table.", - client_info, - e, - ) - connection.close() - await self._disconnect_client(network_writer) - except asyncio.CancelledError: - raise - except (ConnectionError, OSError): - pass - finally: - await self._disconnect_all_clients() - - async def _send_keepalives(self): - """Periodically send IAC NOP to all connected clients to keep sessions alive.""" - - try: - while True: - await asyncio.sleep(self._keepalive_interval) - for network_writer, connection in await self._get_connections_snapshot(): - client_info = self._get_peername(network_writer) - try: - log.debug("Sending keepalive to client %s", client_info) - connection.writer.send_iac(IAC + NOP) - await asyncio.wait_for(connection.writer.drain(), timeout=BROADCAST_DRAIN_TIMEOUT) - except (OSError, ConnectionError, asyncio.TimeoutError) as e: - log.debug( - "Keepalive failed for client %s: %s, closing connection.", - client_info, - e, - ) - connection.close() - await self._disconnect_client(network_writer) - except asyncio.CancelledError: - raise - - async def _get_connections_snapshot(self): - async with self._connections_lock: - return list(self._connections.items()) - - async def _disconnect_all_clients(self): - async with self._connections_lock: - writers = list(self._connections.keys()) - - for network_writer in writers: - await self._disconnect_client(network_writer) - - async def _disconnect_client(self, network_writer): - async with self._connections_lock: - connection = self._connections.pop(network_writer, None) - self._pending_window_sizes.pop(network_writer, None) - - if connection is not None: - with contextlib.suppress(Exception): - await connection.disconnected() - - with contextlib.suppress(AttributeError, OSError): - network_writer.close() - - wait_closed = getattr(network_writer, "wait_closed", None) - if callable(wait_closed): - with contextlib.suppress(ConnectionError, OSError): - await wait_closed() - - @staticmethod - def _get_peername(network_writer): - with contextlib.suppress(OSError, AttributeError): - sock = network_writer.get_extra_info("socket") - if sock is not None: - return sock.getpeername() - return network_writer.get_extra_info("peername") - - @staticmethod - def _set_socket_options(network_writer): sock = network_writer.get_extra_info("socket") - if sock is None: - return - - with contextlib.suppress(OSError): - sock.setsockopt(socket.SOL_SOCKET, socket.SO_KEEPALIVE, 1) - sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1) - - # 60 sec keep alives, close tcp session after 4 missed. - # This keeps stateful firewalls from aging out long-lived sessions. + sock.setsockopt(socket.SOL_SOCKET, socket.SO_KEEPALIVE, 1) + sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1) + # 60 sec keep alives, close tcp session after 4 missed + # Will keep a firewall from aging out telnet console. try: + # Keepalive options are platform dependent: Linux uses TCP_KEEPIDLE, + # while macOS exposes TCP_KEEPALIVE (in Python >= 3.10). if hasattr(socket, "TCP_KEEPIDLE"): sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_KEEPIDLE, 60) elif hasattr(socket, "TCP_KEEPALIVE"): sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_KEEPALIVE, 60) else: - raise AttributeError("No TCP keepalive idle socket option is available") - + raise AttributeError("module 'socket' has no attribute 'TCP_KEEPIDLE' or 'TCP_KEEPALIVE'") sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_KEEPINTVL, 10) sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_KEEPCNT, 4) except (AttributeError, OSError): log.debug("Failed to tune TCP keepalive for telnet client; using OS defaults", exc_info=True) + #log.debug("New connection from {}".format(sock.getpeername())) -if __name__ == "__main__": + # Keep track of connected clients + connection = self._connection_factory(network_reader, network_writer, self._window_size_changed_callback) + self._connections[network_writer] = connection + + try: + await self._write_intro(network_writer, echo=self._echo, binary=self._binary, naws=self._naws) + await connection.connected() + await self._process(network_reader, network_writer, connection) + except (ConnectionError, OSError): + async with self._lock: + network_writer.close() + # await network_writer.wait_closed() # this doesn't work in Python 3.6 + if self._reader_process == network_reader: + self._reader_process = None + # Cancel current read from this reader + if self._current_read is not None: + self._current_read.cancel() + + await connection.disconnected() + del self._connections[network_writer] + + async def close(self): + for writer, connection in self._connections.items(): + try: + writer.write_eof() + await writer.drain() + writer.close() + # await writer.wait_closed() # this doesn't work in Python 3.6 + except (AttributeError, ConnectionError): + continue + + async def client_connected_hook(self): + pass + + async def _get_reader(self, network_reader): + """ + Get a reader or None if another reader is already reading. + """ + async with self._lock: + if self._reader_process is None: + self._reader_process = network_reader + if self._reader: + if self._reader_process == network_reader: + self._current_read = asyncio.ensure_future(self._reader.read(READ_SIZE)) + return self._current_read + return None + + async def _process(self, network_reader, network_writer, connection): + network_read = asyncio.ensure_future(network_reader.read(READ_SIZE)) + reader_read = await self._get_reader(network_reader) + + while True: + if reader_read is None: + reader_read = await self._get_reader(network_reader) + if reader_read is None: + done, pending = await asyncio.wait( + [ + network_read, + ], + timeout=1, + return_when=asyncio.FIRST_COMPLETED) + else: + done, pending = await asyncio.wait( + [ + network_read, + reader_read + ], + return_when=asyncio.FIRST_COMPLETED) + for coro in done: + data = coro.result() + if coro == network_read: + if network_reader.at_eof(): + raise ConnectionResetError() + + network_read = asyncio.ensure_future(network_reader.read(READ_SIZE)) + + if IAC in data: + data = await self._IAC_parser(data, network_reader, network_writer, connection) + + if len(data) == 0: + continue + + if not self._binary: + data = data.replace(b"\r\n", b"\n") + + if self._writer: + self._writer.write(data) + await self._writer.drain() + + await connection.feed(data) + if connection.is_closing: + raise ConnectionResetError() + + elif coro == reader_read: + if self._reader and self._reader.at_eof(): + raise ConnectionResetError() + + reader_read = await self._get_reader(network_reader) + + # Replicate the output on all clients + for connection_key in list(self._connections.keys()): + connection = self._connections[connection_key] + client_info = None + + try: + client_info = connection_key.get_extra_info("socket").getpeername() + connection.writer.write(data) + await asyncio.wait_for(connection.writer.drain(), timeout=10) + except (OSError, ConnectionError, asyncio.TimeoutError) as e: + log.debug(f"Error sending data to client {client_info}: {e}, closing and removing from connection table.") + connection.close() + del self._connections[connection_key] + + async def _read(self, cmd, buffer, location, reader): + """ Reads next op from the buffer or reader""" + try: + op = buffer[location] + cmd.append(op) + return op + except IndexError: + op = await reader.read(1) + buffer.extend(op) + cmd.append(buffer[location]) + return op + + async def _negotiate(self, data, connection): + """ Performs negotiation commands""" + + command, payload = data[0], data[1:] + if command == NAWS: + if len(payload) == 4: + columns, rows = struct.unpack(str('!HH'), bytes(payload)) + await connection.window_size_changed(columns, rows) + else: + log.warning('Wrong number of NAWS bytes') + else: + log.debug("Not supported negotiation sequence, received {} bytes", len(data)) + + async def _IAC_parser(self, buf, network_reader, network_writer, connection): + """ + Processes and removes any Telnet commands from the buffer. + + :param buf: buffer + :returns: buffer minus Telnet commands + """ + + + skip_to = 0 + while True: + # Locate an IAC to process + iac_loc = buf.find(IAC, skip_to) + if iac_loc < 0: + break + + # Get the TELNET command + iac_cmd = bytearray([IAC]) + try: + iac_cmd.append(buf[iac_loc + 1]) + except IndexError: + d = await network_reader.read(1) + buf.extend(d) + iac_cmd.append(buf[iac_loc + 1]) + + # Is this just a 2-byte TELNET command? + if iac_cmd[1] not in [WILL, WONT, DO, DONT, SB]: + if iac_cmd[1] == AYT: + log.debug("Telnet server received Are-You-There (AYT)") + network_writer.write(b'\r\nYour Are-You-There received. I am here.\r\n') + elif iac_cmd[1] == IAC: + # It's data, not an IAC + iac_cmd.pop() + # This prevents the 0xff from being + # interrupted as yet another IAC + skip_to = iac_loc + 1 + log.debug("Received IAC IAC") + elif iac_cmd[1] == NOP: + pass + else: + log.debug("Unhandled telnet command: " + "{0:#x} {1:#x}".format(*iac_cmd)) + elif iac_cmd[1] == SB: # starts negotiation commands + negotiation = [] + for pos in range(2, self.MAX_NEGOTIATION_READ): + op = await self._read(iac_cmd, buf, iac_loc + pos, network_reader) + negotiation.append(op) + if op == SE: + # ends negotiation commands + break + + # SE command is followed by IAC, remove the last two operations from stack + await self._negotiate(negotiation[0:-2], connection) + + # This must be a 3-byte TELNET command + else: + try: + iac_cmd.append(buf[iac_loc + 2]) + except IndexError: + d = await network_reader.read(1) + buf.extend(d) + iac_cmd.append(buf[iac_loc + 2]) + # We do ECHO, SGA, and BINARY. Period. + if iac_cmd[1] == DO: + if iac_cmd[2] not in [ECHO, SGA, BINARY]: + network_writer.write(bytes([IAC, WONT, iac_cmd[2]])) + log.debug("Telnet WON'T {:#x}".format(iac_cmd[2])) + else: + if iac_cmd[2] == SGA: + if self._binary: + network_writer.write(bytes([IAC, WILL, iac_cmd[2]])) + else: + network_writer.write(bytes([IAC, WONT, iac_cmd[2]])) + log.debug("Telnet WON'T {:#x}".format(iac_cmd[2])) + + elif iac_cmd[1] == DONT: + log.debug("Unhandled DONT telnet command: " + "{0:#x} {1:#x} {2:#x}".format(*iac_cmd)) + elif iac_cmd[1] == WILL: + if iac_cmd[2] not in [BINARY, NAWS]: + log.debug("Unhandled WILL telnet command: " + "{0:#x} {1:#x} {2:#x}".format(*iac_cmd)) + elif iac_cmd[1] == WONT: + log.debug("Unhandled WONT telnet command: " + "{0:#x} {1:#x} {2:#x}".format(*iac_cmd)) + else: + log.debug("Unhandled telnet command: " + "{0:#x} {1:#x} {2:#x}".format(*iac_cmd)) + + # Remove the entire TELNET command from the buffer + buf = buf.replace(iac_cmd, b'', 1) + + await network_writer.drain() + + # Return the new copy of the buffer, minus telnet commands + return buf + +if __name__ == '__main__': logging.basicConfig(level=logging.DEBUG) loop = asyncio.get_event_loop() - process = loop.run_until_complete( - asyncio.ensure_future( - asyncio.subprocess.create_subprocess_exec( - "/bin/sh", - "-i", - stdout=asyncio.subprocess.PIPE, - stderr=asyncio.subprocess.STDOUT, - stdin=asyncio.subprocess.PIPE, - ) - ) - ) + process = loop.run_until_complete(asyncio.ensure_future(asyncio.subprocess.create_subprocess_exec("/bin/sh", "-i", + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.STDOUT, + stdin=asyncio.subprocess.PIPE))) server = AsyncioTelnetServer(reader=process.stdout, writer=process.stdin, binary=False, echo=False) - loop.run_until_complete(server.start("127.0.0.1", 4444)) + coro = asyncio.start_server(server.run, '127.0.0.1', 4444) + s = loop.run_until_complete(coro) try: loop.run_forever() except KeyboardInterrupt: pass - - loop.run_until_complete(server.close()) + # Close the server + s.close() + loop.run_until_complete(s.wait_closed()) loop.close() diff --git a/requirements.txt b/requirements.txt index e337e4723..9c322ec53 100644 --- a/requirements.txt +++ b/requirements.txt @@ -10,4 +10,3 @@ distro>=1.9.0 py-cpuinfo>=9.0.0,<10.0 platformdirs>=2.4.0,<3 # platformdirs >=3 conflicts when building Debian packages truststore>=0.10.4; python_version >= '3.10' -telnetlib3==4.0.1 diff --git a/tests/utils/asyncio/test_telnet_server.py b/tests/utils/asyncio/test_telnet_server.py deleted file mode 100644 index d91fb91c9..000000000 --- a/tests/utils/asyncio/test_telnet_server.py +++ /dev/null @@ -1,177 +0,0 @@ -import asyncio -import contextlib - -import pytest -import telnetlib3 - -from gns3server.utils.asyncio.telnet_server import AsyncioTelnetServer - - -class DummyUpstreamWriter: - def __init__(self): - self.buffer = bytearray() - self.write_event = asyncio.Event() - - def write(self, data): - self.buffer.extend(data) - self.write_event.set() - - async def drain(self): - await asyncio.sleep(0) - - -def _get_listen_port(server): - return server.sockets[0].getsockname()[1] - - -async def _wait_for_connection_count(server, expected_count, timeout=2): - deadline = asyncio.get_running_loop().time() + timeout - last_count = 0 - while asyncio.get_running_loop().time() < deadline: - async with server._connections_lock: - last_count = len(server._connections) - if last_count == expected_count: - return - await asyncio.sleep(0.05) - assert last_count == expected_count - - -@pytest.mark.asyncio -async def test_telnet_server_forwards_client_input_to_upstream_writer(): - upstream_reader = asyncio.StreamReader() - upstream_writer = DummyUpstreamWriter() - server = AsyncioTelnetServer(reader=upstream_reader, writer=upstream_writer, binary=True, echo=True) - listener = await server.start("127.0.0.1", 0) - port = _get_listen_port(listener) - - client_writer = None - try: - _, client_writer = await telnetlib3.open_connection("127.0.0.1", port, encoding=False) - await _wait_for_connection_count(server, 1) - client_writer.write(b"ping\r\n") - await client_writer.drain() - - await asyncio.wait_for(upstream_writer.write_event.wait(), timeout=2) - assert b"ping\r\n" in bytes(upstream_writer.buffer) - finally: - if client_writer is not None: - client_writer.close() - listener.close() - await listener.wait_closed() - - -@pytest.mark.asyncio -async def test_telnet_server_broadcasts_upstream_output_to_all_clients(): - upstream_reader = asyncio.StreamReader() - upstream_writer = DummyUpstreamWriter() - server = AsyncioTelnetServer(reader=upstream_reader, writer=upstream_writer, binary=True, echo=True) - listener = await server.start("127.0.0.1", 0) - port = _get_listen_port(listener) - - client1_reader = client1_writer = None - client2_reader = client2_writer = None - - try: - client1_reader, client1_writer = await telnetlib3.open_connection("127.0.0.1", port, encoding=False) - client2_reader, client2_writer = await telnetlib3.open_connection("127.0.0.1", port, encoding=False) - await _wait_for_connection_count(server, 2) - - upstream_reader.feed_data(b"hello") - - data1 = await asyncio.wait_for(client1_reader.read(5), timeout=2) - data2 = await asyncio.wait_for(client2_reader.read(5), timeout=2) - - assert data1 == b"hello" - assert data2 == b"hello" - finally: - if client1_writer is not None: - client1_writer.close() - if client2_writer is not None: - client2_writer.close() - listener.close() - await listener.wait_closed() - - -@pytest.mark.asyncio -async def test_telnet_server_naws_callback_uses_negotiated_dimensions(): - callback_event = asyncio.Event() - callback_result = {} - - async def window_size_changed(columns, rows): - callback_result["columns"] = columns - callback_result["rows"] = rows - callback_event.set() - - server = AsyncioTelnetServer( - binary=True, - echo=True, - naws=True, - window_size_changed_callback=window_size_changed, - ) - listener = await server.start("127.0.0.1", 0) - port = _get_listen_port(listener) - - client_writer = None - try: - _, client_writer = await telnetlib3.open_connection( - "127.0.0.1", port, encoding=False, cols=132, rows=44 - ) - - await asyncio.wait_for(callback_event.wait(), timeout=2) - assert callback_result == {"columns": 132, "rows": 44} - finally: - if client_writer is not None: - client_writer.close() - listener.close() - await listener.wait_closed() - - -@pytest.mark.asyncio -async def test_telnet_server_broadcast_survives_client_disconnect(): - upstream_reader = asyncio.StreamReader() - upstream_writer = DummyUpstreamWriter() - server = AsyncioTelnetServer(reader=upstream_reader, writer=upstream_writer, binary=True, echo=True) - listener = await server.start("127.0.0.1", 0) - port = _get_listen_port(listener) - - client1_writer = None - client2_reader = client2_writer = None - - try: - _, client1_writer = await telnetlib3.open_connection("127.0.0.1", port, encoding=False) - client2_reader, client2_writer = await telnetlib3.open_connection("127.0.0.1", port, encoding=False) - await _wait_for_connection_count(server, 2) - - client1_writer.close() - await _wait_for_connection_count(server, 1) - - upstream_reader.feed_data(b"ok") - data2 = await asyncio.wait_for(client2_reader.read(2), timeout=2) - - assert data2 == b"ok" - async with server._connections_lock: - assert len(server._connections) <= 1 - finally: - if client1_writer is not None: - with contextlib.suppress(Exception): - client1_writer.close() - if client2_writer is not None: - with contextlib.suppress(Exception): - client2_writer.close() - listener.close() - await listener.wait_closed() - - -@pytest.mark.asyncio -async def test_telnet_server_listener_close_cleans_internal_tasks(): - upstream_reader = asyncio.StreamReader() - upstream_writer = DummyUpstreamWriter() - server = AsyncioTelnetServer(reader=upstream_reader, writer=upstream_writer, binary=True, echo=True) - listener = await server.start("127.0.0.1", 0) - - assert server._broadcast_task is not None - - listener.close() - await listener.wait_closed() - - assert server._broadcast_task is None