From e651ad589b238741c31f174a385063266cdc9d66 Mon Sep 17 00:00:00 2001 From: YueGuobin Date: Fri, 27 Mar 2026 02:28:27 +0800 Subject: [PATCH 1/6] feat(symbols): add DELETE API for removing custom symbol files - Add DELETE /v3/symbols/{symbol_id:path} endpoint - Prevent deletion of built-in symbols (:/ prefix or in resource directory) - Clear symbol size cache after deletion - Require Symbol.Allocate privilege --- gns3server/api/routes/controller/symbols.py | 45 ++++++++++++++++++++- 1 file changed, 44 insertions(+), 1 deletion(-) diff --git a/gns3server/api/routes/controller/symbols.py b/gns3server/api/routes/controller/symbols.py index c6dee1315..9701c97ea 100644 --- a/gns3server/api/routes/controller/symbols.py +++ b/gns3server/api/routes/controller/symbols.py @@ -27,7 +27,8 @@ from typing import List from gns3server.controller import Controller from gns3server import schemas -from gns3server.controller.controller_error import ControllerError, ControllerNotFoundError +from gns3server.controller.controller_error import ControllerError, ControllerNotFoundError, ControllerForbiddenError +from gns3server.utils.get_resource import get_resource from .dependencies.rbac import has_privilege @@ -131,3 +132,45 @@ async def upload_symbol(symbol_id: str, request: Request) -> None: # Reset the symbol list controller.symbols.list() + + +@router.delete( + "/{symbol_id:path}", + status_code=status.HTTP_204_NO_CONTENT, + dependencies=[Depends(has_privilege("Symbol.Allocate"))] +) +async def delete_symbol(symbol_id: str) -> None: + """ + Delete a custom symbol file. + + Required privilege: Symbol.Allocate + """ + + controller = Controller.instance() + + # Cannot delete built-in symbols + if symbol_id.startswith(":/"): + raise ControllerForbiddenError("Cannot delete built-in symbols") + + try: + symbol_path = controller.symbols.get_path(symbol_id) + except (KeyError, ControllerNotFoundError) as e: + raise ControllerNotFoundError(f"Symbol '{symbol_id}' not found: {e}") + + # Check if it's a built-in symbol (in resource directory) + symbols_resource_dir = get_resource("symbols") + if symbols_resource_dir and os.path.commonprefix([symbols_resource_dir, symbol_path]) == symbols_resource_dir: + raise ControllerForbiddenError("Cannot delete built-in symbols") + + # Delete the file + try: + os.remove(symbol_path) + log.info(f"Deleted symbol file '{symbol_path}'") + except OSError as e: + raise ControllerError(f"Could not delete symbol file '{symbol_path}': {e}") + + # Clear the symbol size cache + controller.symbols._symbol_size_cache.pop(symbol_id, None) + + # Reset the symbol list + controller.symbols.list() From b8764b29a503c7bff074581a3195f1869a409d66 Mon Sep 17 00:00:00 2001 From: grossmj Date: Fri, 27 Mar 2026 10:57:59 +0800 Subject: [PATCH 2/6] Upgrade FastAPI to v0.135.2 --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 4a32f8eda..069b305ba 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,7 +1,7 @@ # GNS3 Server Core Dependencies uvicorn==0.41.0 pydantic==2.12.5 -fastapi==0.135.1 +fastapi==0.135.2 python-multipart==0.0.22 websockets==16.0 aiohttp>=3.13.3,<3.14 From 7f9416a070d8161419c86e2ddf3340a82032d7f3 Mon Sep 17 00:00:00 2001 From: YueGuobin Date: Sat, 28 Mar 2026 02:11:30 +0800 Subject: [PATCH 3/6] feat(link): add control_offset field to LinkStyle --- gns3server/schemas/controller/links.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/gns3server/schemas/controller/links.py b/gns3server/schemas/controller/links.py index 606d267af..56684f309 100644 --- a/gns3server/schemas/controller/links.py +++ b/gns3server/schemas/controller/links.py @@ -15,7 +15,7 @@ # along with this program. If not, see . from pydantic import BaseModel, Field -from typing import List, Optional +from typing import List, Optional, Tuple from enum import Enum from uuid import UUID, uuid4 @@ -50,6 +50,7 @@ class LinkStyle(BaseModel): link_type: Optional[str] = None bezier_curviness: Optional[int] = None flowchart_roundness: Optional[int] = None + control_offset: Optional[Tuple[float, float]] = None class LinkBase(BaseModel): From 01a68fb23a621811a21787cf4e57d8befdb46c69 Mon Sep 17 00:00:00 2001 From: grossmj Date: Sat, 28 Mar 2026 09:54:22 +0800 Subject: [PATCH 4/6] Use FastAPI Lifespan Events --- gns3server/api/server.py | 6 +-- gns3server/core/tasks.py | 103 ++++++++++++++++++++------------------- 2 files changed, 55 insertions(+), 54 deletions(-) diff --git a/gns3server/api/server.py b/gns3server/api/server.py index 888822978..bebddfdc9 100644 --- a/gns3server/api/server.py +++ b/gns3server/api/server.py @@ -46,7 +46,6 @@ from gns3server.controller.controller_error import ( from gns3server.api.routes import controller, index from gns3server.api.routes.compute import compute_api from gns3server.core import tasks -from gns3server.version import __version__ import logging @@ -56,6 +55,7 @@ log = logging.getLogger(__name__) def get_application() -> FastAPI: application = FastAPI( + lifespan=tasks.lifespan, title="GNS3 controller API", description="This page describes the public controller API for GNS3", version="v3", @@ -71,8 +71,8 @@ def get_application() -> FastAPI: allow_headers=["*"], ) - application.add_event_handler("startup", tasks.create_startup_handler(application)) - application.add_event_handler("shutdown", tasks.create_shutdown_handler(application)) + #application.add_event_handler("startup", tasks.create_startup_handler(application)) + #application.add_event_handler("shutdown", tasks.create_shutdown_handler(application)) application.include_router(index.router, tags=["Index"]) application.include_router(controller.router, prefix="/v3") application.mount("/static", StaticFiles(packages=[('gns3server', 'static')]), name="static") diff --git a/gns3server/core/tasks.py b/gns3server/core/tasks.py index 6b5869dd6..99ab6c122 100644 --- a/gns3server/core/tasks.py +++ b/gns3server/core/tasks.py @@ -17,8 +17,8 @@ import asyncio -from typing import Callable from fastapi import FastAPI +from contextlib import asynccontextmanager from gns3server.controller import Controller from gns3server.config import Config @@ -35,74 +35,75 @@ log = logging.getLogger(__name__) auto_discover_images_task_handle = None -def create_startup_handler(app: FastAPI) -> Callable: +@asynccontextmanager +async def lifespan(app: FastAPI): + + await startup(app) + yield + await shutdown(app) + + +async def startup(app: FastAPI) -> None: """ Tasks to be performed when the server is starting. """ - async def start_app() -> None: - loop = asyncio.get_event_loop() - logger = logging.getLogger("asyncio") - logger.setLevel(logging.ERROR) + loop = asyncio.get_event_loop() + logger = logging.getLogger("asyncio") + logger.setLevel(logging.ERROR) - if log.getEffectiveLevel() == logging.DEBUG: - # On debug version we enable info that - # coroutine is not called in a way await/await - loop.set_debug(True) + if log.getEffectiveLevel() == logging.DEBUG: + # On debug version we enable info that + # coroutine is not called in a way await/await + loop.set_debug(True) - # connect to the database - await connect_to_db(app) + # connect to the database + await connect_to_db(app) - # retrieve the computes from the database - computes = await get_computes(app) + # retrieve the computes from the database + computes = await get_computes(app) - await Controller.instance().start(computes) + await Controller.instance().start(computes) - # Because with a large image collection - # without md5sum already computed we start the - # computing with server start - from gns3server.compute.qemu import Qemu + # Because with a large image collection + # without md5sum already computed we start the + # computing with server start + from gns3server.compute.qemu import Qemu - if Config.instance().settings.Server.auto_discover_images is True: - # Start the discovering new images on file system 5 seconds after the server has started - # to give it a chance to process API requests - global auto_discover_images_task_handle - auto_discover_images_task_handle = asyncio.get_event_loop().call_later( - 5, - lambda: asyncio.create_task(discover_images_on_filesystem(app)) - ) + if Config.instance().settings.Server.auto_discover_images is True: + # Start the discovering new images on file system 5 seconds after the server has started + # to give it a chance to process API requests + global auto_discover_images_task_handle + auto_discover_images_task_handle = asyncio.get_event_loop().call_later( + 5, + lambda: asyncio.create_task(discover_images_on_filesystem(app)) + ) - for module in MODULES: - log.debug(f"Loading module {module.__name__}") - m = module.instance() - m.port_manager = PortManager.instance() - - return start_app + for module in MODULES: + log.debug(f"Loading module {module.__name__}") + m = module.instance() + m.port_manager = PortManager.instance() -def create_shutdown_handler(app: FastAPI) -> Callable: +async def shutdown(app: FastAPI) -> None: """ Tasks to be performed when the server is exiting. """ - async def shutdown_handler() -> None: + if auto_discover_images_task_handle is not None and not auto_discover_images_task_handle.cancelled(): + auto_discover_images_task_handle.cancel() + await HTTPClient.close_session() + await Controller.instance().stop() - if auto_discover_images_task_handle is not None and not auto_discover_images_task_handle.cancelled(): - auto_discover_images_task_handle.cancel() - await HTTPClient.close_session() - await Controller.instance().stop() + for module in MODULES: + log.debug(f"Unloading module {module.__name__}") + m = module.instance() + await m.unload() - for module in MODULES: - log.debug(f"Unloading module {module.__name__}") - m = module.instance() - await m.unload() + if PortManager.instance().tcp_ports: + log.warning(f"TCP ports are still used {PortManager.instance().tcp_ports}") - if PortManager.instance().tcp_ports: - log.warning(f"TCP ports are still used {PortManager.instance().tcp_ports}") + if PortManager.instance().udp_ports: + log.warning(f"UDP ports are still used {PortManager.instance().udp_ports}") - if PortManager.instance().udp_ports: - log.warning(f"UDP ports are still used {PortManager.instance().udp_ports}") - - await disconnect_from_db(app) - - return shutdown_handler + await disconnect_from_db(app) From 46f77eb7d57ef14f496889724dd88d4eeca40dbd Mon Sep 17 00:00:00 2001 From: grossmj Date: Sat, 28 Mar 2026 09:59:29 +0800 Subject: [PATCH 5/6] Remove old add_event_handler --- gns3server/api/server.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/gns3server/api/server.py b/gns3server/api/server.py index bebddfdc9..87a7b24f0 100644 --- a/gns3server/api/server.py +++ b/gns3server/api/server.py @@ -71,8 +71,6 @@ def get_application() -> FastAPI: allow_headers=["*"], ) - #application.add_event_handler("startup", tasks.create_startup_handler(application)) - #application.add_event_handler("shutdown", tasks.create_shutdown_handler(application)) application.include_router(index.router, tags=["Index"]) application.include_router(controller.router, prefix="/v3") application.mount("/static", StaticFiles(packages=[('gns3server', 'static')]), name="static") From 4cd74901a5384d0edbd54a839dfdfd93a6648374 Mon Sep 17 00:00:00 2001 From: YueGuobin Date: Mon, 30 Mar 2026 14:09:36 +0800 Subject: [PATCH 6/6] fix(copilot): improve device_type error message with tested types and config instructions Add tested device types (cisco_ios_telnet, gns3_huawei_telnet_ce, gns3_ruijie_telnet) to the error message and include Web UI configuration path. --- gns3server/agent/gns3_copilot/utils/get_gns3_device_port.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/gns3server/agent/gns3_copilot/utils/get_gns3_device_port.py b/gns3server/agent/gns3_copilot/utils/get_gns3_device_port.py index 821bddf62..3ac1bc992 100644 --- a/gns3server/agent/gns3_copilot/utils/get_gns3_device_port.py +++ b/gns3server/agent/gns3_copilot/utils/get_gns3_device_port.py @@ -107,9 +107,15 @@ def get_device_ports_from_topology( # Return error if device_type not found in tags # Using a default would cause command execution errors if device_type is None: + tested_device_types = ( + "cisco_ios_telnet (Netmiko built-in), gns3_huawei_telnet_ce (custom Huawei), " + "gns3_ruijie_telnet (custom Ruijie)" + ) error_msg = ( f"Device '{device_name}': device_type tag not found. " f"Please add 'device_type:' tag to this device in GNS3. " + f"To configure via Web UI: right-click the device -> Configure -> Tags -> add 'device_type:'. " + f"Tested types: {tested_device_types}. " f"Current tags: {tags}" ) logger.error(error_msg)