diff --git a/.github/workflows/testing.yml b/.github/workflows/testing.yml index 744115355..cb84817ea 100644 --- a/.github/workflows/testing.yml +++ b/.github/workflows/testing.yml @@ -18,11 +18,7 @@ jobs: strategy: matrix: os: ["ubuntu-latest"] - python-version: ["3.8", "3.9", "3.10", "3.11", "3.12", "3.13", "3.14"] - #include: - # only test with Python 3.10 on Windows - # - os: windows-latest - # python-version: "3.10" + python-version: ["3.9", "3.10", "3.11", "3.12", "3.13", "3.14"] steps: - uses: actions/checkout@v4 diff --git a/CHANGELOG b/CHANGELOG index 15c9573b3..63db2f004 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -1,5 +1,11 @@ # Change Log +## 2.2.56.1 28/01/2026 + +* Fix telnet keepalive options on macOS +* Upgrade dependencies +* Drop Python 3.8 support + ## 2.2.56 21/01/2026 * Set default location of udhcpc in "/etc/network/udhcpc". Fixes #2582 diff --git a/dev-requirements.txt b/dev-requirements.txt index 4348b0d94..ece129f97 100644 --- a/dev-requirements.txt +++ b/dev-requirements.txt @@ -1,6 +1,6 @@ -rrequirements.txt -pytest==8.3.3 -flake8==7.1.0 -pytest-timeout==2.3.1 -pytest-aiohttp==1.0.5 +pytest==8.4.2 # version 8.4.2 is the last one supporting Python 3.9 +flake8==7.3.0 +pytest-timeout==2.4.0 +pytest-aiohttp==1.1.0 diff --git a/gns3server/appliances/asterfusion-vAsterNOS-VPP.gns3a b/gns3server/appliances/asterfusion-vAsterNOS-VPP.gns3a index 3afa29fa5..d8f8ed557 100644 --- a/gns3server/appliances/asterfusion-vAsterNOS-VPP.gns3a +++ b/gns3server/appliances/asterfusion-vAsterNOS-VPP.gns3a @@ -9,6 +9,7 @@ "registry_version": 6, "status": "stable", "maintainer": "Asterfusion Product Team", + "maintainer_email": "bd@cloudswit.ch", "images": [ { "filename": "AsterNOS-VPP_V6.1-R0101P02_x86.img.gz", diff --git a/gns3server/crash_report.py b/gns3server/crash_report.py index 93f503113..5898bbb7d 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://9b9fce29bf59d9a99c230240e1070600@o19455.ingest.us.sentry.io/38482" + DSN = "https://5bfcec70239c428d44aafef68f3eea79@o19455.ingest.us.sentry.io/38482" _instance = None def __init__(self): diff --git a/gns3server/run.py b/gns3server/run.py index 32467d45a..c58bd6e92 100644 --- a/gns3server/run.py +++ b/gns3server/run.py @@ -235,9 +235,9 @@ def run(): return log.info("HTTP authentication is enabled with username '{}'".format(user)) - # we only support Python 3 version >= 3.8 - if sys.version_info < (3, 8, 0): - raise SystemExit("Python 3.8 or higher is required") + # we only support Python 3 version >= 3.9 + if sys.version_info < (3, 9, 0): + raise SystemExit("Python 3.9 or higher is required") user_log.info("Running with Python {major}.{minor}.{micro} and has PID {pid}".format(major=sys.version_info[0], minor=sys.version_info[1], micro=sys.version_info[2], pid=os.getpid())) diff --git a/gns3server/utils/asyncio/__init__.py b/gns3server/utils/asyncio/__init__.py index efc32d066..7e464373c 100644 --- a/gns3server/utils/asyncio/__init__.py +++ b/gns3server/utils/asyncio/__init__.py @@ -91,25 +91,16 @@ async def wait_for_process_termination(process, timeout=10): In theory this can be implemented by just: await asyncio.wait_for(self._iou_process.wait(), timeout=100) - But it's broken before Python 3.4: - http://bugs.python.org/issue23140 - :param process: An asyncio subprocess :param timeout: Timeout in seconds """ - if sys.version_info >= (3, 5): - try: - await asyncio.wait_for(process.wait(), timeout=timeout) - except ProcessLookupError: + while timeout > 0: + if process.returncode is not None: return - else: - while timeout > 0: - if process.returncode is not None: - return - await asyncio.sleep(0.1) - timeout -= 0.1 - raise asyncio.TimeoutError() + await asyncio.sleep(0.1) + timeout -= 0.1 + raise asyncio.TimeoutError() async def _check_process(process, termination_callback): diff --git a/gns3server/utils/asyncio/pool.py b/gns3server/utils/asyncio/pool.py index 22750eadf..444863295 100644 --- a/gns3server/utils/asyncio/pool.py +++ b/gns3server/utils/asyncio/pool.py @@ -40,10 +40,7 @@ class Pool(): while len(self._tasks) > 0 or len(pending) > 0: while len(self._tasks) > 0 and len(pending) < self._concurrency: task, args, kwargs = self._tasks.pop(0) - if sys.version_info >= (3, 7): - t = asyncio.create_task(task(*args, **kwargs)) - else: - t = asyncio.get_event_loop().create_task(task(*args, **kwargs)) + t = asyncio.create_task(task(*args, **kwargs)) pending.add(t) (done, pending) = await asyncio.wait(pending, return_when=asyncio.FIRST_COMPLETED) for task in done: diff --git a/gns3server/utils/asyncio/telnet_server.py b/gns3server/utils/asyncio/telnet_server.py index 50f7defc4..b13c405cd 100644 --- a/gns3server/utils/asyncio/telnet_server.py +++ b/gns3server/utils/asyncio/telnet_server.py @@ -186,15 +186,25 @@ class AsyncioTelnetServer: await writer.drain() 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. - sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_KEEPIDLE, 60) - sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_KEEPINTVL, 10) - sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_KEEPCNT, 4) + 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'") + 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 diff --git a/gns3server/version.py b/gns3server/version.py index a5a0c9036..b4b765e96 100644 --- a/gns3server/version.py +++ b/gns3server/version.py @@ -23,7 +23,7 @@ # or negative for a release candidate or beta (after the base version # number has been incremented) -__version__ = "2.2.56" +__version__ = "2.2.56.1" __version_info__ = (2, 2, 56, 0) if "dev" in __version__: diff --git a/requirements.txt b/requirements.txt index 5f0b26144..9c322ec53 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,13 +1,12 @@ -jsonschema>=4.23,<4.24 -aiohttp>=3.10.11,<3.11 # version 3.10.11 is the last compatible version with Python 3.8 -aiohttp-cors>=0.7.0,<0.8 -aiofiles>=24.1.0,<25.0 +jsonschema>=4.25.1,<4.26 # version 4.25.1 is the last to support Python 3.9 +aiohttp>=3.13.3,<3.14 +aiohttp-cors>=0.8.1,<0.9 +aiofiles>=25.1.0,<26.0 Jinja2>=3.1.6,<3.2 -sentry-sdk>=2.44.0,<2.45 # optional dependency -psutil>=7.1.3 -async-timeout>=5.0.1,<5.1 +sentry-sdk>=2.50.0,<3 # optional dependency +psutil>=7.2.1 +async-timeout>=5.0.1,<5.1 # this library has effectively been upstreamed into Python 3.11+ distro>=1.9.0 py-cpuinfo>=9.0.0,<10.0 platformdirs>=2.4.0,<3 # platformdirs >=3 conflicts when building Debian packages -importlib-resources>=1.3; python_version < '3.9' -truststore>=0.10.0; python_version >= '3.10' +truststore>=0.10.4; python_version >= '3.10' diff --git a/scripts/random_query.py b/scripts/random_query.py deleted file mode 100644 index f5e7de2a9..000000000 --- a/scripts/random_query.py +++ /dev/null @@ -1,238 +0,0 @@ -#!/usr/bin/env python -# -# Copyright (C) 2016 GNS3 Technologies Inc. -# -# This program is free software: you can redistribute it and/or modify -# it under the terms of the GNU General Public License as published by -# the Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU General Public License for more details. -# -# You should have received a copy of the GNU General Public License -# along with this program. If not, see . - -""" -This script connect to the local GNS3 server and will create a random topology -""" - -import sys -import json -import math -import aiohttp -import aiohttp.web -import asyncio -import random - -import coloredlogs -import logging - -coloredlogs.install(fmt=" %(asctime)s %(levelname)s %(message)s") -log = logging.getLogger(__name__) - -PROJECT_ID = "9e26e37d-4962-4921-8c0e-136d3b04ba9c" -HOST = "192.168.84.151:3080" - -# Use for node names uniqueness -node_i = 1 - - -def die(*args): - log.error(*args) - sys.exit(1) - - -class HTTPError(Exception): - - def __init__(self, method, path, response): - self._method = method - self._path = path - self._response = response - - @property - def response(self): - return self._response - - @property - def path(self): - return self._path - - @property - def method(self): - return self._method - - -class HTTPConflict(HTTPError): - pass - - -class HTTPNotFound(HTTPError): - pass - - -async def query(method, path, body=None, **kwargs): - global session - - if body: - kwargs["data"] = json.dumps(body) - - async with session.request(method, "http://" + HOST + "/v2" + path, **kwargs) as response: - if response.status == 409: - raise HTTPConflict(method, path, response) - elif response.status == 404: - raise HTTPNotFound(method, path, response) - elif response.status >= 300: - raise HTTPError(method, path, response) - log.info("%s %s %d", method, path, response.status) - if response.headers["content-type"] == "application/json": - return await response.json() - else: - return "{}" - - -async def post(path, **kwargs): - return await query("POST", path, **kwargs) - - -async def get(path, **kwargs): - return await query("GET", path, **kwargs) - - -async def delete(path, **kwargs): - return await query("DELETE", path, **kwargs) - - -async def create_project(): - # Delete project if already exists - response = await get("/projects") - project_exists = False - for project in response: - if project["name"] == "random" and project["project_id"] != PROJECT_ID: - await delete("/projects/" + project["project_id"]) - elif project["project_id"] == PROJECT_ID: - project_exists = True - tasks = [] - for node in await get("/projects/" + PROJECT_ID + "/nodes"): - tasks.append(delete_node(project, node)) - await asyncio.gather(*tasks) - if project_exists: - response = await post("/projects/" + PROJECT_ID + "/open") - else: - response = await post("/projects", body={"name": "random", "project_id": PROJECT_ID, "auto_close": False}) - return response - - -async def create_node(project): - global node_i - - r = random.randint(0, 1) - - if r == 0: - node_type = "ethernet_switch" - symbol = ":/symbols/ethernet_switch.svg" - elif r == 1: - node_type = "vpcs" - symbol = ":/symbols/vpcs_guest.svg" - response = await post("/projects/{}/nodes".format(project["project_id"]), body={ - "node_type": node_type, - "compute_id": "local", - "symbol": symbol, - "name": "Node{}".format(node_i), - "x": (math.floor((node_i - 1) % 12.0) * 100) - 500, - "y": (math.ceil((node_i) / 12.0) * 100) - 300 - }) - node_i += 1 - return response - - -async def delete_node(project, node): - await delete("/projects/{}/nodes/{}".format(project["project_id"], node["node_id"])) - - -async def create_link(project, nodes): - """ - Create all possible link of a node - """ - node1 = random.choice(list(nodes.values())) - - for port in range(0, 8): - node2 = random.choice(list(nodes.values())) - - if node1 == node2: - continue - - data = {"nodes": - [ - { - "adapter_number": 0, - "node_id": node1["node_id"], - "port_number": port - }, - { - "adapter_number": 0, - "node_id": node2["node_id"], - "port_number": port - } - ] - } - try: - await post("/projects/{}/links".format(project["project_id"]), body=data) - except (HTTPConflict, HTTPNotFound): - pass - - -async def build_topology(): - global node_i - - nodes = {} - project = await create_project() - while True: - rand = random.randint(0, 1000) - if rand < 500: # chance to create a new node - if len(nodes.keys()) < 255: # Limit of VPCS: - node = await create_node(project) - nodes[node["node_id"]] = node - elif rand < 600: # start all nodes - await post("/projects/{}/nodes/start".format(project["project_id"])) - elif rand < 700: # stop all nodes - await post("/projects/{}/nodes/stop".format(project["project_id"])) - elif rand < 950: # create a link - if len(nodes.keys()) >= 2: - await create_link(project, nodes) - elif rand < 999: # chance to delete a node - continue - if len(nodes.keys()) > 0: - node = random.choice(list(nodes.values())) - await delete_node(project, node) - del nodes[node["node_id"]] - elif len(nodes.keys()) > 0: # % chance to delete all nodes - continue - node_i = 1 - tasks = [] - for node in nodes.values(): - tasks.append(delete_node(project, node)) - await asyncio.gather(*tasks) - nodes = {} - await asyncio.sleep(0.2) - -async def main(loop): - global session - async with aiohttp.ClientSession() as session: - try: - await build_topology() - except HTTPError as error: - try: - j = await error.response.json() - die("%s %s invalid status %d:\n%s", error.method, error.path, error.response.status, json.dumps(j, indent=4)) - except (ValueError, aiohttp.ServerDisconnectedError): - die("%s %s invalid status %d", error.method, error.path, error.response.status) - - -loop = asyncio.get_event_loop() -loop.run_until_complete(main(loop)) - -if session: - session.close() diff --git a/setup.py b/setup.py index 5685ce694..2cc398f69 100644 --- a/setup.py +++ b/setup.py @@ -21,8 +21,8 @@ from setuptools import setup, find_packages from setuptools.command.test import test as TestCommand # we only support Python 3 version >= 3.8 -if len(sys.argv) >= 2 and sys.argv[1] == "install" and sys.version_info < (3, 8): - raise SystemExit("Python 3.8 or higher is required") +if len(sys.argv) >= 2 and sys.argv[1] == "install" and sys.version_info < (3, 9): + raise SystemExit("Python 3.9 or higher is required") class PyTest(TestCommand): @@ -78,7 +78,6 @@ setup( "Operating System :: Microsoft :: Windows", "Programming Language :: Python", "Programming Language :: Python :: 3 :: Only", - "Programming Language :: Python :: 3.8", "Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11",