From 6415d2e56c9caf58dad279d05bb4ee2c3f326eee Mon Sep 17 00:00:00 2001 From: grossmj Date: Mon, 6 Jul 2026 19:06:25 +0200 Subject: [PATCH 1/6] Update dependencies --- dev-requirements.txt | 2 +- requirements.txt | 20 ++++++++++---------- 2 files changed, 11 insertions(+), 11 deletions(-) diff --git a/dev-requirements.txt b/dev-requirements.txt index bc2aa8404..d32b59862 100644 --- a/dev-requirements.txt +++ b/dev-requirements.txt @@ -1,4 +1,4 @@ -pytest==9.0.3 # fix CVE-2025-71176; Python 3.10+ required +pytest==9.1.1 # fix CVE-2025-71176; Python 3.10+ required flake8==7.3.0 pytest-timeout==2.4.0 pytest-asyncio==1.4.0 diff --git a/requirements.txt b/requirements.txt index 9986b177e..7b4615b00 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,23 +1,23 @@ # GNS3 Server Core Dependencies -uvicorn==0.48.0 +uvicorn==0.50.2 pydantic==2.13.4 fastapi==0.136.3 -python-multipart==0.0.30 +python-multipart==0.0.32 websockets==16.0 aiohttp>=3.14,<3.15 aiofiles>=25.1.0,<26.0 Jinja2>=3.1.6,<3.2 -sentry-sdk>=2.61.1,<3 # optional dependency +sentry-sdk>=2.64.0,<3 # optional dependency psutil>=7.2.2 async-timeout>=5.0.1,<5.1; python_version < '3.11' # this library has effectively been upstreamed into Python 3.11+ distro>=1.9.0 py-cpuinfo>=9.0.0,<10.0 -greenlet==3.5.1; python_version >= '3.13' # necessary to run sqlalchemy on Python >= 3.13 -sqlalchemy==2.0.50 +greenlet==3.5.3; python_version >= '3.13' # necessary to run sqlalchemy on Python >= 3.13 +sqlalchemy==2.0.51 aiosqlite==0.22.1 -alembic==1.18.4 +alembic==1.18.5 bcrypt==5.0.0 -joserfc==1.7.0 +joserfc==1.7.2 email-validator==2.3.0 watchdog==6.0.0 zstandard==0.25.0 @@ -25,9 +25,9 @@ platformdirs>=2.4.0 # fastmcp-slim >=3.4 requires >=4.0.0; upper bound removed truststore>=0.10.4; python_version >= '3.10' # Shared dependencies (also used by AI Copilot) -telnetlib3==4.0.4 -asyncssh>=2.23.0,<3 -typing-extensions>=4.15.0 +telnetlib3==4.0.5 +asyncssh>=2.24.0,<3 +typing-extensions>=4.16.0 requests>=2.34.2 urllib3>=2.7.0 From e3e3a7e729b5847a1daac7b435089a410c89e082 Mon Sep 17 00:00:00 2001 From: grossmj Date: Mon, 6 Jul 2026 19:21:56 +0200 Subject: [PATCH 2/6] Update fastapi to v0.139.0 --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 7b4615b00..bc82a8cdf 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,7 +1,7 @@ # GNS3 Server Core Dependencies uvicorn==0.50.2 pydantic==2.13.4 -fastapi==0.136.3 +fastapi==0.139.0 python-multipart==0.0.32 websockets==16.0 aiohttp>=3.14,<3.15 From 021991410383733c724f7aa1db525ad71b99a146 Mon Sep 17 00:00:00 2001 From: grossmj Date: Tue, 11 Aug 2026 18:47:02 +0200 Subject: [PATCH 3/6] fix: breaking change with dependency FastAPI v0.137.0 https://fastapi.tiangolo.com/release-notes/#specific-breaking-changes --- gns3server/api/routes/controller/acl.py | 62 ++++++++++++++---- tests/api/routes/controller/test_acl.py | 1 + tests/api/routes/test_routes.py | 87 ++++++++++++++++++------- 3 files changed, 115 insertions(+), 35 deletions(-) diff --git a/gns3server/api/routes/controller/acl.py b/gns3server/api/routes/controller/acl.py index 3f80154ec..3ec65e874 100644 --- a/gns3server/api/routes/controller/acl.py +++ b/gns3server/api/routes/controller/acl.py @@ -22,9 +22,10 @@ API routes for ACL. import re from fastapi import APIRouter, Depends, Request, status -from fastapi.routing import APIRoute +from fastapi.routing import APIRoute, _IncludedRouter +from starlette.routing import BaseRoute, Mount from uuid import UUID -from typing import List +from typing import Iterator, List, Sequence from gns3server import schemas @@ -48,6 +49,39 @@ log = logging.getLogger(__name__) router = APIRouter() +def _join_paths(prefix: str, path: str) -> str: + + if not prefix: + return path + + normalized_prefix = prefix + while normalized_prefix.endswith("/"): + normalized_prefix = normalized_prefix[:-1] + + normalized_path = path + while normalized_path.startswith("/"): + normalized_path = normalized_path[1:] + + return f"{normalized_prefix}/{normalized_path}".rstrip("/") + + +def _iter_route_paths(routes: Sequence[BaseRoute], prefix: str = "", include_mounted_routes=False) -> Iterator[str]: + for route in routes: + if isinstance(route, _IncludedRouter): + include_prefix = route.include_context.prefix or "" + yield from _iter_route_paths(route.original_router.routes, _join_paths(prefix, include_prefix), include_mounted_routes) + continue + + if isinstance(route, APIRoute): + yield _join_paths(prefix, route.path) + continue + + if isinstance(route, Mount) and include_mounted_routes: + mounted_routes = getattr(route, "routes", None) + if isinstance(mounted_routes, Sequence): + yield from _iter_route_paths(mounted_routes, _join_paths(prefix, route.path), include_mounted_routes) + + @router.get( "/endpoints", status_code=status.HTTP_201_CREATED, @@ -179,19 +213,21 @@ async def create_ace( Required privilege: ACE.Allocate """ - for route in request.app.routes: - if isinstance(route, APIRoute): + for route_path in _iter_route_paths(request.app.routes, include_mounted_routes=True): + print(route_path) - # remove the prefix (e.g. "/v3") from the route path - route_path = re.sub(r"^/v[0-9]", "", route.path) - # replace route path ID parameters by a UUID regex - route_path = re.sub(r"{\w+_id}", "[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}", route_path) - # replace remaining route path parameters by a word matching regex - route_path = re.sub(r"/{[\w:]+}", r"/\\w+", route_path) + for route_path in _iter_route_paths(request.app.routes, include_mounted_routes=True): - if re.fullmatch(route_path, ace_create.path): - log.info(f"Creating ACE for route path {route_path}") - return await rbac_repo.create_ace(ace_create) + # remove the prefix (e.g. "/v3") from the route path + normalized_path = re.sub(r"^/v[0-9]", "", route_path) + # replace route path ID parameters by a UUID regex + normalized_path = re.sub(r"{\w+_id}", "[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}", normalized_path) + # replace remaining route path parameters by a word matching regex + normalized_path = re.sub(r"/{[\w:]+}", r"/\\w+", normalized_path) + + if re.fullmatch(normalized_path, ace_create.path): + log.info(f"Creating ACE for route path {route_path}") + return await rbac_repo.create_ace(ace_create) raise ControllerBadRequestError(f"Path '{ace_create.path}' doesn't match any existing endpoint") diff --git a/tests/api/routes/controller/test_acl.py b/tests/api/routes/controller/test_acl.py index 4524b2ee9..83be66e1b 100644 --- a/tests/api/routes/controller/test_acl.py +++ b/tests/api/routes/controller/test_acl.py @@ -87,6 +87,7 @@ class TestACLRoutes: response = await authorized_client.post(app.url_path_for("create_project"), json={"name": "test"}) assert response.status_code == status.HTTP_201_CREATED + async def test_create_ace_not_existing_endpoint( self, app: FastAPI, diff --git a/tests/api/routes/test_routes.py b/tests/api/routes/test_routes.py index 8d671b979..5c8a59166 100644 --- a/tests/api/routes/test_routes.py +++ b/tests/api/routes/test_routes.py @@ -17,11 +17,12 @@ import pytest from fastapi import FastAPI, status -from fastapi.routing import APIRoute, APIWebSocketRoute -from starlette.routing import Mount +from fastapi.routing import APIRoute, APIWebSocketRoute, _IncludedRouter +from starlette.routing import BaseRoute, Mount from httpx import AsyncClient from httpx_ws import aconnect_ws from httpx_ws.transport import ASGIWebSocketTransport +from typing import Iterator, Sequence, Tuple @@ -46,6 +47,43 @@ ALLOWED_CONTROLLER_ENDPOINTS = [ ("/v3/mcp/", "GET"), ] + +def _join_paths(prefix: str, path: str) -> str: + if not prefix: + return path + + normalized_prefix = prefix + while normalized_prefix.endswith("/"): + normalized_prefix = normalized_prefix[:-1] + + normalized_path = path + while normalized_path.startswith("/"): + normalized_path = normalized_path[1:] + + return f"{normalized_prefix}/{normalized_path}" + + +def _iter_routes( + routes: Sequence[BaseRoute], + prefix: str = "", + include_mounted_routes: bool = False +) -> Iterator[Tuple[str, BaseRoute]]: + for route in routes: + if isinstance(route, _IncludedRouter): + include_prefix = route.include_context.prefix or "" + yield from _iter_routes(route.original_router.routes, _join_paths(prefix, include_prefix), include_mounted_routes) + continue + + if isinstance(route, (APIRoute, APIWebSocketRoute)): + yield _join_paths(prefix, route.path), route + continue + + if isinstance(route, Mount) and include_mounted_routes: + mounted_routes = getattr(route, "routes", None) + if isinstance(mounted_routes, Sequence): + yield from _iter_routes(mounted_routes, _join_paths(prefix, route.path), include_mounted_routes) + + class TestRoutes: # Controller endpoints have a OAuth2 bearer token authentication @@ -55,16 +93,17 @@ class TestRoutes: unauthorized_client: AsyncClient ) -> None: - for route in app.routes: + for path, route in _iter_routes(app.routes): if isinstance(route, APIRoute): for method in list(route.methods): - if (route.path, method) not in ALLOWED_CONTROLLER_ENDPOINTS: - response = await getattr(unauthorized_client, method.lower())(route.path) - assert response.status_code == status.HTTP_401_UNAUTHORIZED - elif isinstance(route, APIWebSocketRoute): + if (path, method) not in ALLOWED_CONTROLLER_ENDPOINTS: + request_path = path.rstrip("/") + response = await getattr(unauthorized_client, method.lower())(request_path) + assert response.status_code == status.HTTP_401_UNAUTHORIZED, f"{method} {request_path} -> {response.status_code}" + elif isinstance(route, APIWebSocketRoute) and not path.startswith("/v3/compute"): params = {"token": "wrong_token"} async with AsyncClient(base_url="http://test-api", transport=ASGIWebSocketTransport(app=app)) as client: - async with aconnect_ws(route.path, client, params=params) as ws: + async with aconnect_ws(path, client, params=params) as ws: json_notification = await ws.receive_json() assert json_notification['event'] == { 'message': 'Could not authenticate while connecting to controller WebSocket: Could not validate credentials' @@ -78,17 +117,21 @@ class TestRoutes: unauthorized_client: AsyncClient ) -> None: - for route in app.routes: - if isinstance(route, Mount): - for compute_route in route.routes: - if isinstance(compute_route, APIRoute): - for method in list(compute_route.methods): - response = await getattr(unauthorized_client, method.lower())(route.path + compute_route.path) - assert response.status_code == status.HTTP_401_UNAUTHORIZED - elif isinstance(compute_route, APIWebSocketRoute): - async with AsyncClient(base_url="http://test-api", transport=ASGIWebSocketTransport(app=app)) as client: - async with aconnect_ws(route.path + compute_route.path, client, auth=("wrong_user", "password123")) as ws: - json_notification = await ws.receive_json() - assert json_notification['event'] == { - 'message': 'Could not authenticate while connecting to compute WebSocket: Could not validate credentials' - } + for path, route in _iter_routes(app.routes, include_mounted_routes=True): + if not path.startswith("/v3/compute"): + continue + + if isinstance(route, APIRoute): + for method in list(route.methods): + request_path = path.rstrip("/") + response = await getattr(unauthorized_client, method.lower())(request_path) + #if response.status_code == status.HTTP_307_TEMPORARY_REDIRECT: + # response = await getattr(unauthorized_client, method.lower())(response.headers["location"]) + assert response.status_code == status.HTTP_401_UNAUTHORIZED, f"{method} {request_path} -> {response.status_code}" + elif isinstance(route, APIWebSocketRoute): + async with AsyncClient(base_url="http://test-api", transport=ASGIWebSocketTransport(app=app)) as client: + async with aconnect_ws(path, client, auth=("wrong_user", "password123")) as ws: + json_notification = await ws.receive_json() + assert json_notification['event'] == { + 'message': 'Could not authenticate while connecting to compute WebSocket: Could not validate credentials' + } From 9e1c533847d69d82aa9929230312a590db93ef00 Mon Sep 17 00:00:00 2001 From: grossmj Date: Tue, 11 Aug 2026 18:47:35 +0200 Subject: [PATCH 4/6] Finish to upgrade other dependencies --- dev-requirements.txt | 2 +- requirements.txt | 14 +++++++------- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/dev-requirements.txt b/dev-requirements.txt index d32b59862..55aac6eaa 100644 --- a/dev-requirements.txt +++ b/dev-requirements.txt @@ -1,4 +1,4 @@ -pytest==9.1.1 # fix CVE-2025-71176; Python 3.10+ required +pytest==9.1.1 flake8==7.3.0 pytest-timeout==2.4.0 pytest-asyncio==1.4.0 diff --git a/requirements.txt b/requirements.txt index bc82a8cdf..8587fa87b 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,13 +1,13 @@ # GNS3 Server Core Dependencies -uvicorn==0.50.2 +uvicorn==0.52.1 pydantic==2.13.4 -fastapi==0.139.0 +fastapi==0.141.1 python-multipart==0.0.32 -websockets==16.0 +websockets==17.0.1 aiohttp>=3.14,<3.15 aiofiles>=25.1.0,<26.0 Jinja2>=3.1.6,<3.2 -sentry-sdk>=2.64.0,<3 # optional dependency +sentry-sdk>=2.67.1,<3 # optional dependency psutil>=7.2.2 async-timeout>=5.0.1,<5.1; python_version < '3.11' # this library has effectively been upstreamed into Python 3.11+ distro>=1.9.0 @@ -15,9 +15,9 @@ py-cpuinfo>=9.0.0,<10.0 greenlet==3.5.3; python_version >= '3.13' # necessary to run sqlalchemy on Python >= 3.13 sqlalchemy==2.0.51 aiosqlite==0.22.1 -alembic==1.18.5 +alembic==1.19.1 bcrypt==5.0.0 -joserfc==1.7.2 +joserfc==1.7.4 email-validator==2.3.0 watchdog==6.0.0 zstandard==0.25.0 @@ -25,7 +25,7 @@ platformdirs>=2.4.0 # fastmcp-slim >=3.4 requires >=4.0.0; upper bound removed truststore>=0.10.4; python_version >= '3.10' # Shared dependencies (also used by AI Copilot) -telnetlib3==4.0.5 +telnetlib3==5.0.0 asyncssh>=2.24.0,<3 typing-extensions>=4.16.0 requests>=2.34.2 From 386c88f230013114c449e9ff7735c10d491aa959 Mon Sep 17 00:00:00 2001 From: grossmj Date: Tue, 11 Aug 2026 18:52:09 +0200 Subject: [PATCH 5/6] Downgrade websockets version --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 8587fa87b..090c557b0 100644 --- a/requirements.txt +++ b/requirements.txt @@ -3,7 +3,7 @@ uvicorn==0.52.1 pydantic==2.13.4 fastapi==0.141.1 python-multipart==0.0.32 -websockets==17.0.1 +websockets==16.1.1 # version 16.1.1 is the last version that supports Python 3.10 aiohttp>=3.14,<3.15 aiofiles>=25.1.0,<26.0 Jinja2>=3.1.6,<3.2 From 1988768d328e38c1c63cdb720a1bd680f12b6176 Mon Sep 17 00:00:00 2001 From: grossmj Date: Tue, 11 Aug 2026 19:23:18 +0200 Subject: [PATCH 6/6] fix: update CI configuration to use a single OS and remove Windows-specific dependencies --- .github/workflows/testing.yml | 13 ++----------- 1 file changed, 2 insertions(+), 11 deletions(-) diff --git a/.github/workflows/testing.yml b/.github/workflows/testing.yml index 4ec75343d..20c1cf5ea 100644 --- a/.github/workflows/testing.yml +++ b/.github/workflows/testing.yml @@ -13,11 +13,10 @@ on: jobs: build: - runs-on: ${{ matrix.os }} + runs-on: ubuntu-latest strategy: matrix: - os: ["ubuntu-latest"] python-version: ["3.10", "3.11", "3.12", "3.13", "3.14"] steps: @@ -26,20 +25,12 @@ jobs: uses: actions/setup-python@v6 with: python-version: ${{ matrix.python-version }} - - name: Display Python version - run: python -c "import sys; print(sys.version)" + cache: pip - name: Install dependencies run: | python -m pip install --upgrade pip python -m pip install .[ai-features,dev] - - name: Install Windows specific dependencies - if: runner.os == 'Windows' - run: | - python -m pip install -r win-requirements.txt - curl -O "http://www.win10pcap.org/download/Win10Pcap-v10.2-5002.msi" - msiexec /i "Win10Pcap-v10.2-5002.msi" /qn /norestart - - name: Lint with flake8 run: | # stop the build if there are Python syntax errors or undefined names