diff --git a/gns3server/api/routes/controller/acl.py b/gns3server/api/routes/controller/acl.py
index a454a1d61..49f814966 100644
--- a/gns3server/api/routes/controller/acl.py
+++ b/gns3server/api/routes/controller/acl.py
@@ -80,7 +80,11 @@ def _iter_route_paths(routes: Sequence[BaseRoute], prefix: str = "", include_mou
yield from _iter_route_paths(mounted_routes, _join_paths(prefix, route.path), include_mounted_routes)
-@router.get("/endpoints", dependencies=[Depends(has_privilege("ACE.Audit"))])
+@router.get(
+ "/endpoints",
+ response_model=List[schemas.ACEEndpoint],
+ dependencies=[Depends(has_privilege("ACE.Audit"))],
+)
async def endpoints(
users_repo: UsersRepository = Depends(get_repository(UsersRepository)),
rbac_repo: RbacRepository = Depends(get_repository(RbacRepository)),
diff --git a/gns3server/api/routes/controller/api_keys.py b/gns3server/api/routes/controller/api_keys.py
index 42f1d66c2..c3297e612 100644
--- a/gns3server/api/routes/controller/api_keys.py
+++ b/gns3server/api/routes/controller/api_keys.py
@@ -50,12 +50,12 @@ def _generate_api_key(api_key_id: UUID | None = None) -> tuple[str, str, str, UU
return raw_key, key_hash, key_prefix, api_key_id
-@router.post("", status_code=status.HTTP_201_CREATED)
+@router.post("", response_model=schemas.ApiKeyCreated, status_code=status.HTTP_201_CREATED)
async def create_api_key(
api_key_data: schemas.ApiKeyCreate,
current_user: schemas.User = Depends(get_current_active_user),
api_keys_repo: ApiKeysRepository = Depends(get_repository(ApiKeysRepository)),
-) -> dict:
+) -> schemas.ApiKeyCreated:
"""Create a new API key. The full key is returned only once."""
raw_key, key_hash, key_prefix, new_key_id = _generate_api_key()
@@ -66,42 +66,42 @@ async def create_api_key(
key_hash=key_hash,
key_prefix=key_prefix,
)
- return {
- "api_key_id": str(db_key.api_key_id),
- "api_key": raw_key,
- "name": db_key.name,
- "key_prefix": db_key.key_prefix,
- "created_at": db_key.created_at.isoformat() if db_key.created_at else None,
- }
+ return schemas.ApiKeyCreated(
+ api_key_id=db_key.api_key_id,
+ api_key=raw_key,
+ name=db_key.name,
+ key_prefix=db_key.key_prefix,
+ created_at=db_key.created_at.isoformat() if db_key.created_at else None,
+ )
-@router.get("")
+@router.get("", response_model=list[schemas.ApiKey])
async def list_api_keys(
current_user: schemas.User = Depends(get_current_active_user),
api_keys_repo: ApiKeysRepository = Depends(get_repository(ApiKeysRepository)),
-) -> list[dict]:
+) -> list[schemas.ApiKey]:
"""List all API keys for the current user."""
keys = await api_keys_repo.get_api_keys_by_user(current_user.user_id)
return [
- {
- "api_key_id": str(k.api_key_id),
- "name": k.name,
- "key_prefix": k.key_prefix,
- "created_at": k.created_at.isoformat() if k.created_at else None,
- "last_used_at": k.last_used_at.isoformat() if k.last_used_at else None,
- "revoked": k.revoked,
- }
+ schemas.ApiKey(
+ api_key_id=k.api_key_id,
+ name=k.name,
+ key_prefix=k.key_prefix,
+ created_at=k.created_at.isoformat() if k.created_at else None,
+ last_used_at=k.last_used_at.isoformat() if k.last_used_at else None,
+ revoked=k.revoked,
+ )
for k in keys
]
-@router.post("/{api_key_id}/revoke", status_code=status.HTTP_200_OK)
+@router.post("/{api_key_id}/revoke", response_model=schemas.ApiKeyMessage, status_code=status.HTTP_200_OK)
async def revoke_api_key(
api_key_id: UUID,
current_user: schemas.User = Depends(get_current_active_user),
api_keys_repo: ApiKeysRepository = Depends(get_repository(ApiKeysRepository)),
-) -> dict:
+) -> schemas.ApiKeyMessage:
"""Revoke an API key. It will immediately stop working, but can be restored."""
key = await api_keys_repo.get_api_key(api_key_id)
@@ -111,15 +111,15 @@ async def revoke_api_key(
raise HTTPException(status_code=403, detail="Cannot modify another user's API key")
await api_keys_repo.revoke_api_key(api_key_id)
- return {"message": f"API key '{key.name}' revoked"}
+ return schemas.ApiKeyMessage(message=f"API key '{key.name}' revoked")
-@router.post("/{api_key_id}/restore", status_code=status.HTTP_200_OK)
+@router.post("/{api_key_id}/restore", response_model=schemas.ApiKeyMessage, status_code=status.HTTP_200_OK)
async def restore_api_key(
api_key_id: UUID,
current_user: schemas.User = Depends(get_current_active_user),
api_keys_repo: ApiKeysRepository = Depends(get_repository(ApiKeysRepository)),
-) -> dict:
+) -> schemas.ApiKeyMessage:
"""Restore a previously revoked API key."""
key = await api_keys_repo.get_api_key(api_key_id)
@@ -129,7 +129,7 @@ async def restore_api_key(
raise HTTPException(status_code=403, detail="Cannot modify another user's API key")
await api_keys_repo.restore_api_key(api_key_id)
- return {"message": f"API key '{key.name}' restored"}
+ return schemas.ApiKeyMessage(message=f"API key '{key.name}' restored")
@router.delete("/{api_key_id}", status_code=status.HTTP_204_NO_CONTENT)
diff --git a/gns3server/api/routes/controller/appliances.py b/gns3server/api/routes/controller/appliances.py
index 1d3f0e413..c20554a9f 100644
--- a/gns3server/api/routes/controller/appliances.py
+++ b/gns3server/api/routes/controller/appliances.py
@@ -82,6 +82,8 @@ def get_appliance(appliance_id: UUID) -> schemas.Appliance:
@router.post(
"/{appliance_id}/version",
+ response_model=schemas.Appliance,
+ response_model_exclude_unset=True,
status_code=status.HTTP_201_CREATED,
dependencies=[Depends(has_privilege("Appliance.Allocate"))],
)
diff --git a/gns3server/api/routes/controller/computes.py b/gns3server/api/routes/controller/computes.py
index a33ebc9f6..796a72063 100644
--- a/gns3server/api/routes/controller/computes.py
+++ b/gns3server/api/routes/controller/computes.py
@@ -212,7 +212,7 @@ async def vmware_vms(compute_id: Union[str, UUID]) -> List[schemas.ComputeVMware
return result
-@router.post("/{compute_id}/dynamips/auto_idlepc")
+@router.post("/{compute_id}/dynamips/auto_idlepc", response_model=schemas.IdlePC)
async def dynamips_autoidlepc(compute_id: Union[str, UUID], auto_idle_pc: schemas.AutoIdlePC):
"""
Find a suitable Idle-PC value for a given IOS image. This may take a few minutes.
diff --git a/gns3server/api/routes/controller/controller.py b/gns3server/api/routes/controller/controller.py
index cef3ee77e..a6a499372 100644
--- a/gns3server/api/routes/controller/controller.py
+++ b/gns3server/api/routes/controller/controller.py
@@ -162,7 +162,12 @@ async def update_iou_license(iou_license: schemas.IOULicense) -> schemas.IOULice
return current_iou_license
-@router.get("/statistics", dependencies=[Depends(get_current_active_user)])
+@router.get(
+ "/statistics",
+ response_model=schemas.ServerStatistics,
+ response_model_exclude_unset=True,
+ dependencies=[Depends(get_current_active_user)],
+)
async def statistics() -> dict:
"""
Return server statistics including compute resources, projects, and nodes.
diff --git a/gns3server/api/routes/controller/gns3vm.py b/gns3server/api/routes/controller/gns3vm.py
index e0ae90eba..21d2cdf49 100644
--- a/gns3server/api/routes/controller/gns3vm.py
+++ b/gns3server/api/routes/controller/gns3vm.py
@@ -30,7 +30,7 @@ from gns3server.controller import Controller
router = APIRouter()
-@router.get("/engines")
+@router.get("/engines", response_model=List[schemas.GNS3VMEngineInfo])
async def get_engines() -> List[dict]:
"""
Return the list of supported engines for the GNS3VM.
@@ -40,7 +40,7 @@ async def get_engines() -> List[dict]:
return gns3_vm.engine_list()
-@router.get("/engines/{engine}/vms")
+@router.get("/engines/{engine}/vms", response_model=List[schemas.GNS3VMInfo])
async def get_vms(engine: str) -> List[dict]:
"""
Return all the available VMs for a specific virtualization engine.
diff --git a/gns3server/api/routes/controller/images.py b/gns3server/api/routes/controller/images.py
index c82f2464a..4593a4f97 100644
--- a/gns3server/api/routes/controller/images.py
+++ b/gns3server/api/routes/controller/images.py
@@ -329,7 +329,12 @@ async def prune_images(
)
-@router.post("/install", status_code=status.HTTP_200_OK, dependencies=[Depends(has_privilege("Image.Allocate"))])
+@router.post(
+ "/install",
+ response_model=schemas.ImageInstallResult,
+ status_code=status.HTTP_200_OK,
+ dependencies=[Depends(has_privilege("Image.Allocate"))],
+)
async def install_images(
images_repo: ImagesRepository = Depends(get_repository(ImagesRepository)),
templates_repo: TemplatesRepository = Depends(get_repository(TemplatesRepository)),
diff --git a/gns3server/api/routes/controller/links.py b/gns3server/api/routes/controller/links.py
index 79a471787..d17119624 100644
--- a/gns3server/api/routes/controller/links.py
+++ b/gns3server/api/routes/controller/links.py
@@ -20,7 +20,7 @@ API routes for links.
import logging
import os
-from typing import Any, List, Optional
+from typing import Any, Dict, List, Optional
from uuid import UUID, uuid4
import aiohttp
@@ -33,8 +33,13 @@ from gns3server import schemas
from gns3server.agent.web_wireshark.manager import WebWiresharkManager
from gns3server.api.openapi import PCAP_MEDIA_TYPE, binary_response
from gns3server.controller import Controller
-from gns3server.controller.controller_error import ControllerError, ControllerNotFoundError
+from gns3server.controller.controller_error import (
+ ControllerError,
+ ControllerNotFoundError,
+ controller_error_status_code,
+)
from gns3server.controller.link import _UNSET, Link
+from gns3server.controller.project import Project
from gns3server.db.repositories.rbac import RbacRepository
from gns3server.utils.http_client import HTTPClient
from gns3server.utils.port_allocator import link_id_to_port
@@ -123,7 +128,7 @@ async def get_links(project_id: UUID) -> List[schemas.Link]:
},
dependencies=[Depends(has_privilege("Link.Allocate"))],
)
-async def create_link(project_id: UUID, link_create: schemas.LinkCreate) -> schemas.Link:
+async def create_link(project_id: UUID, link_create: schemas.LinkCreate) -> dict:
"""
Create a new link.
@@ -131,17 +136,22 @@ async def create_link(project_id: UUID, link_create: schemas.LinkCreate) -> sche
"""
project = await Controller.instance().get_loaded_project(str(project_id))
+ return await _create_link(project, link_create)
+
+
+async def _create_link(project: Project, link_create: schemas.LinkCreate) -> dict:
+
link = await project.add_link()
link_data = jsonable_encoder(link_create, exclude_unset=True)
- if "filters" in link_data:
- await link.update_filters(link_data["filters"])
- if "link_style" in link_data:
- await link.update_link_style(link_data["link_style"])
- if "suspend" in link_data:
- await link.update_suspend(link_data["suspend"])
- if "show_filters_icon" in link_data:
- await link.update_show_filters_icon(link_data["show_filters_icon"])
try:
+ if "filters" in link_data:
+ await link.update_filters(link_data["filters"])
+ if "link_style" in link_data:
+ await link.update_link_style(link_data["link_style"])
+ if "suspend" in link_data:
+ await link.update_suspend(link_data["suspend"])
+ if "show_filters_icon" in link_data:
+ await link.update_show_filters_icon(link_data["show_filters_icon"])
endpoints = link_data["nodes"]
attached_port = None
for index, endpoint in enumerate(endpoints):
@@ -159,13 +169,47 @@ async def create_link(project_id: UUID, link_create: schemas.LinkCreate) -> sche
return link.asdict()
+@router.post(
+ "/batch",
+ response_model=List[schemas.LinkBatchResult],
+ responses={
+ 404: {"model": schemas.ErrorMessage, "description": "Could not find project"},
+ },
+ dependencies=[Depends(has_privilege("Link.Allocate"))],
+)
+async def create_links(project_id: UUID, links_create: List[schemas.LinkCreate]) -> List[schemas.LinkBatchResult]:
+ """
+ Create several links in the order they are listed.
+
+ The response lists one result per link in the same order. A link that fails to be created
+ does not stop the following ones.
+
+ Required privilege: Link.Allocate
+ """
+
+ project = await Controller.instance().get_loaded_project(str(project_id))
+ results = []
+ for link_create in links_create:
+ try:
+ link = await _create_link(project, link_create)
+ results.append(schemas.LinkBatchResult(status_code=status.HTTP_201_CREATED, link=link))
+ except ControllerError as e:
+ log.error(f"Could not create link in batch: {e}")
+ results.append(
+ schemas.LinkBatchResult(
+ status_code=controller_error_status_code(e), error=schemas.ErrorMessage(message=str(e))
+ )
+ )
+ return results
+
+
@router.get(
"/{link_id}/available_filters",
- response_model=List[schemas.LinkFilterDefinition],
+ response_model=List[schemas.LinkFilterInfo],
response_model_exclude_unset=True,
dependencies=[Depends(has_privilege("Link.Audit"))],
)
-async def get_filters(link: Link = Depends(dep_link)) -> List[schemas.LinkFilterDefinition]:
+async def get_filters(link: Link = Depends(dep_link)) -> List[dict]:
"""
Return all filters available for a given link.
@@ -309,6 +353,7 @@ async def stop_capture(link: Link = Depends(dep_link)) -> None:
@router.post(
"/{link_id}/capture/wireshark/restart",
+ response_model=schemas.LinkWiresharkRestart,
status_code=status.HTTP_200_OK,
dependencies=[Depends(has_privilege("Link.Capture"))],
)
@@ -466,7 +511,12 @@ async def web_wireshark_websocket(
pass
-@router.get("/{link_id}/markers", dependencies=[Depends(has_privilege("Link.Audit"))])
+@router.get(
+ "/{link_id}/markers",
+ response_model=Dict[str, schemas.Marker],
+ response_model_exclude_unset=True,
+ dependencies=[Depends(has_privilege("Link.Audit"))],
+)
async def get_markers(link: Link = Depends(dep_link)) -> dict:
"""
Return all traffic-insight markers configured on this link.
@@ -478,7 +528,11 @@ async def get_markers(link: Link = Depends(dep_link)) -> dict:
@router.post(
- "/{link_id}/markers", status_code=status.HTTP_201_CREATED, dependencies=[Depends(has_privilege("Link.Modify"))]
+ "/{link_id}/markers",
+ response_model=schemas.Marker,
+ response_model_exclude_unset=True,
+ status_code=status.HTTP_201_CREATED,
+ dependencies=[Depends(has_privilege("Link.Modify"))],
)
async def create_marker(marker_data: schemas.MarkerCreate, link: Link = Depends(dep_link)) -> dict:
"""
@@ -523,7 +577,12 @@ async def delete_marker(marker_name: str, link: Link = Depends(dep_link)) -> Non
await link.stop_marker(marker_name)
-@router.put("/{link_id}/markers/{marker_name}", dependencies=[Depends(has_privilege("Link.Modify"))])
+@router.put(
+ "/{link_id}/markers/{marker_name}",
+ response_model=schemas.Marker,
+ response_model_exclude_unset=True,
+ dependencies=[Depends(has_privilege("Link.Modify"))],
+)
async def update_marker(marker_name: str, marker_data: schemas.MarkerUpdate, link: Link = Depends(dep_link)) -> dict:
"""
Update a traffic-insight marker (change BPF, tag, or enabled).
diff --git a/gns3server/api/routes/controller/nodes.py b/gns3server/api/routes/controller/nodes.py
index 4000f0da6..988524835 100644
--- a/gns3server/api/routes/controller/nodes.py
+++ b/gns3server/api/routes/controller/nodes.py
@@ -37,8 +37,10 @@ from gns3server.config import Config
from gns3server.controller import Controller
from gns3server.controller.controller_error import (
ControllerBadRequestError,
+ ControllerError,
ControllerForbiddenError,
ControllerTimeoutError,
+ controller_error_status_code,
)
from gns3server.controller.node import Node
from gns3server.controller.project import Project
@@ -141,13 +143,20 @@ def _check_node_type(node: Node, *required_types: str) -> None:
},
dependencies=[Depends(has_privilege("Node.Allocate"))],
)
-async def create_node(node_create: schemas.NodeCreate, project: Project = Depends(dep_project)) -> schemas.Node:
+async def create_node(node_create: schemas.NodeCreate, project: Project = Depends(dep_project)) -> dict:
"""
Create a new node.
+ Set strict_names to get a 409 error instead of an automatic rename when the node name is already used.
+
Required privilege: Node.Allocate
"""
+ return await _create_node(project, node_create)
+
+
+async def _create_node(project: Project, node_create: schemas.NodeCreate) -> dict:
+
controller = Controller.instance()
compute = controller.get_compute(str(node_create.compute_id))
node_data = jsonable_encoder(node_create, exclude_unset=True)
@@ -155,6 +164,41 @@ async def create_node(node_create: schemas.NodeCreate, project: Project = Depend
return node.asdict()
+@router.post(
+ "/batch",
+ response_model=List[schemas.NodeBatchResult],
+ responses={
+ 404: {"model": schemas.ErrorMessage, "description": "Could not find project"},
+ },
+ dependencies=[Depends(has_privilege("Node.Allocate"))],
+)
+async def create_nodes(
+ nodes_create: List[schemas.NodeCreate], project: Project = Depends(dep_project)
+) -> List[schemas.NodeBatchResult]:
+ """
+ Create several nodes in the order they are listed.
+
+ The response lists one result per node in the same order. A node that fails to be created
+ does not stop the following ones.
+
+ Required privilege: Node.Allocate
+ """
+
+ results = []
+ for node_create in nodes_create:
+ try:
+ node = await _create_node(project, node_create)
+ results.append(schemas.NodeBatchResult(status_code=status.HTTP_201_CREATED, node=node))
+ except ControllerError as e:
+ log.error(f"Could not create node {node_create.name} in batch: {e}")
+ results.append(
+ schemas.NodeBatchResult(
+ status_code=controller_error_status_code(e), error=schemas.ErrorMessage(message=str(e))
+ )
+ )
+ return results
+
+
@router.get(
"",
response_model=List[schemas.Node],
@@ -529,7 +573,11 @@ async def get_node_links(node: Node = Depends(dep_node)) -> List[schemas.Link]:
return links
-@router.get("/{node_id}/dynamips/auto_idlepc", dependencies=[Depends(has_privilege("Node.Audit"))])
+@router.get(
+ "/{node_id}/dynamips/auto_idlepc",
+ response_model=schemas.IdlePC,
+ dependencies=[Depends(has_privilege("Node.Audit"))],
+)
async def auto_idlepc(node: Node = Depends(dep_node)) -> dict:
"""
Compute an Idle-PC value for a Dynamips node
diff --git a/gns3server/api/routes/controller/projects.py b/gns3server/api/routes/controller/projects.py
index aceb39811..3736aba39 100644
--- a/gns3server/api/routes/controller/projects.py
+++ b/gns3server/api/routes/controller/projects.py
@@ -31,7 +31,7 @@ import gns3server.utils.zipfile_zstd as zipfile
log = logging.getLogger()
-from typing import Any, List, Optional
+from typing import Any, Dict, List, Optional
from uuid import UUID
from fastapi import (
@@ -237,7 +237,9 @@ async def delete_project(
await rbac_repo.delete_all_ace_starting_with_path(f"/projects/{project.id}")
-@router.get("/{project_id}/stats", dependencies=[Depends(has_privilege("Project.Audit"))])
+@router.get(
+ "/{project_id}/stats", response_model=schemas.ProjectStats, dependencies=[Depends(has_privilege("Project.Audit"))]
+)
def get_project_stats(project: Project = Depends(dep_project)) -> dict:
"""
Return a project statistics.
@@ -248,7 +250,12 @@ def get_project_stats(project: Project = Depends(dep_project)) -> dict:
return project.stats()
-@router.get("/{project_id}/markers", dependencies=[Depends(has_privilege("Project.Audit"))])
+@router.get(
+ "/{project_id}/markers",
+ response_model=Dict[str, schemas.ProjectMarker],
+ response_model_exclude_unset=True,
+ dependencies=[Depends(has_privilege("Project.Audit"))],
+)
def get_project_markers(project: Project = Depends(dep_project)) -> dict:
"""
Return all traffic-insight markers across every link in the project.
@@ -279,6 +286,7 @@ async def _replay_response(awaitable):
@router.get(
"/{project_id}/markers/tags/{tag}/replay/range",
+ response_model=schemas.ReplayTimeline,
dependencies=[Depends(has_privilege("Project.Audit"))],
)
async def replay_tag_range(
@@ -319,6 +327,7 @@ async def replay_tag_range(
@router.get(
"/{project_id}/markers/tags/{tag}/replay/frames",
+ response_model=schemas.ReplayFrames,
dependencies=[Depends(has_privilege("Project.Audit"))],
)
async def replay_tag_frames(
@@ -359,6 +368,8 @@ async def replay_tag_frames(
@router.get(
"/{project_id}/markers/tags/{tag}/replay/frame/detail",
+ response_model=schemas.ReplayFrameDetail,
+ response_model_exclude_unset=True,
dependencies=[Depends(has_privilege("Project.Audit"))],
)
async def replay_tag_frame_detail(
@@ -397,7 +408,12 @@ async def replay_tag_frame_detail(
# ---------------------------------------------------------------------------
-@router.get("/{project_id}/marker-definitions", dependencies=[Depends(has_privilege("Project.Audit"))])
+@router.get(
+ "/{project_id}/marker-definitions",
+ response_model=Dict[str, schemas.MarkerDefinitionWithLinks],
+ response_model_exclude_unset=True,
+ dependencies=[Depends(has_privilege("Project.Audit"))],
+)
def get_marker_definitions(project: Project = Depends(dep_project)) -> dict:
"""
Return all project-level marker definitions with their bound link IDs.
@@ -419,6 +435,8 @@ def get_marker_definitions(project: Project = Depends(dep_project)) -> dict:
@router.post(
"/{project_id}/marker-definitions",
+ response_model=schemas.MarkerDefinition,
+ response_model_exclude_unset=True,
status_code=status.HTTP_201_CREATED,
dependencies=[Depends(has_privilege("Project.Modify"))],
)
@@ -446,7 +464,12 @@ async def create_marker_definition(
return project.marker_definitions.get(name, {})
-@router.put("/{project_id}/marker-definitions/{def_name}", dependencies=[Depends(has_privilege("Project.Modify"))])
+@router.put(
+ "/{project_id}/marker-definitions/{def_name}",
+ response_model=schemas.MarkerDefinition,
+ response_model_exclude_unset=True,
+ dependencies=[Depends(has_privilege("Project.Modify"))],
+)
async def update_marker_definition(
def_name: str, def_data: schemas.MarkerDefinitionCreate, project: Project = Depends(dep_project)
) -> dict:
diff --git a/gns3server/api/routes/controller/symbols.py b/gns3server/api/routes/controller/symbols.py
index a9c80741a..afa004398 100644
--- a/gns3server/api/routes/controller/symbols.py
+++ b/gns3server/api/routes/controller/symbols.py
@@ -21,7 +21,7 @@ API routes for symbols.
import logging
import os
-from typing import List
+from typing import Dict, List
from fastapi import APIRouter, Depends, Request, Response, status
from fastapi.responses import FileResponse
@@ -40,7 +40,7 @@ log = logging.getLogger(__name__)
router = APIRouter()
-@router.get("", dependencies=[Depends(has_privilege("Symbol.Audit"))])
+@router.get("", response_model=List[schemas.SymbolInfo], dependencies=[Depends(has_privilege("Symbol.Audit"))])
def get_symbols() -> List[dict]:
"""
Return all symbols.
@@ -83,6 +83,7 @@ async def get_symbol(symbol_id: str, request: Request) -> Response:
@router.get(
"/{symbol_id:path}/dimensions",
+ response_model=schemas.SymbolDimensions,
responses={404: {"model": schemas.ErrorMessage, "description": "Could not find symbol"}},
dependencies=[Depends(has_privilege("Symbol.Audit"))],
)
@@ -102,7 +103,11 @@ async def get_symbol_dimensions(symbol_id: str) -> dict:
raise ControllerNotFoundError(f"Could not get symbol file: {e}")
-@router.get("/default_symbols", dependencies=[Depends(has_privilege("Symbol.Audit"))])
+@router.get(
+ "/default_symbols",
+ response_model=Dict[str, Dict[str, str]],
+ dependencies=[Depends(has_privilege("Symbol.Audit"))],
+)
def get_default_symbols() -> dict:
"""
Return all default symbols.
diff --git a/gns3server/controller/controller_error.py b/gns3server/controller/controller_error.py
index ac6a747aa..da661eb0d 100644
--- a/gns3server/controller/controller_error.py
+++ b/gns3server/controller/controller_error.py
@@ -86,3 +86,21 @@ class ComputeConflictError(ComputeError):
def response(self):
return self._response
+
+
+def controller_error_status_code(error: ControllerError) -> int:
+ """
+ HTTP status code returned by the API for a controller error.
+ """
+
+ if isinstance(error, ControllerTimeoutError):
+ return 408
+ if isinstance(error, ControllerUnauthorizedError):
+ return 401
+ if isinstance(error, ControllerForbiddenError):
+ return 403
+ if isinstance(error, ControllerNotFoundError):
+ return 404
+ if isinstance(error, ControllerBadRequestError):
+ return 400
+ return 409
diff --git a/gns3server/controller/node.py b/gns3server/controller/node.py
index 4468165e7..5df81d8b1 100644
--- a/gns3server/controller/node.py
+++ b/gns3server/controller/node.py
@@ -101,7 +101,9 @@ class Node:
"default_password",
]
- def __init__(self, project, compute, name, node_id=None, node_type=None, template_id=None, **kwargs):
+ def __init__(
+ self, project, compute, name, node_id=None, node_type=None, template_id=None, strict_names=False, **kwargs
+ ):
"""
:param project: Project of the node
:param compute: Compute where the server will run
@@ -109,6 +111,7 @@ class Node:
:param node_id: UUID of the node (integer)
:param node_type: Type of emulator
:param template_id: Template ID used to create this node
+ :param strict_names: Fail instead of renaming the node when the name is already used
:param kwargs: Node properties
"""
@@ -126,7 +129,7 @@ class Node:
self._label = None
self._links = set()
self._name = None
- self.name = name
+ self._name = project.update_node_name(self, name, strict=strict_names)
self._console = None
self._console_type = None
self._aux = None
diff --git a/gns3server/controller/project.py b/gns3server/controller/project.py
index 76d54b3ae..52f8bc65a 100644
--- a/gns3server/controller/project.py
+++ b/gns3server/controller/project.py
@@ -557,17 +557,20 @@ class Project:
if name in self._allocated_node_names:
self._allocated_node_names.remove(name)
- def update_allocated_node_name(self, base_name):
+ def update_allocated_node_name(self, base_name, strict=False):
"""
Updates a node name or generate a new if no node
name is available.
:param base_name: new node base name
+ :param strict: raise an error instead of renaming when the name is already taken
"""
if base_name is None:
return None
base_name = re.sub(r"[ ]", "", base_name) # remove spaces in node name
+ if strict and base_name in self._allocated_node_names:
+ raise ControllerError(f"Node name {base_name} is already used")
if base_name in self._allocated_node_names:
base_name = re.sub(r"[0-9]+$", "{0}", base_name)
@@ -595,11 +598,11 @@ class Project:
return name
raise ControllerError("A node name could not be allocated (node limit reached?)")
- def update_node_name(self, node, new_name):
+ def update_node_name(self, node, new_name, strict=False):
if new_name and node.name != new_name:
self.remove_allocated_node_name(node.name)
- return self.update_allocated_node_name(new_name)
+ return self.update_allocated_node_name(new_name, strict=strict)
return new_name
@open_required
@@ -630,26 +633,32 @@ class Project:
node = await self.add_node(compute, name, node_id, node_type=node_type, **template)
return node
- async def _create_node(self, compute, name, node_id, node_type=None, allow_missing_image=False, **kwargs):
+ async def _create_node(
+ self, compute, name, node_id, node_type=None, allow_missing_image=False, strict_names=False, **kwargs
+ ):
- node = Node(self, compute, name, node_id=node_id, node_type=node_type, **kwargs)
- # Hold the lock across the check + POST + register so that concurrent
- # node creations on the same compute don't all race past the check and
- # each POST /projects (the compute-side sync handler then instantiated
- # the Project N times). Once one creation registers the compute, the
- # rest see it in the set and return immediately.
- async with self._create_node_lock:
- if compute not in self._project_created_on_compute:
- if compute.id == "local":
- data = {"name": self._name, "project_id": self._id, "path": self._path}
- else:
- data = {"name": self._name, "project_id": self._id}
- if self._variables:
- data["variables"] = self._variables
- await compute.post("/projects", data=data)
- self._project_created_on_compute.add(compute)
+ node = Node(self, compute, name, node_id=node_id, node_type=node_type, strict_names=strict_names, **kwargs)
+ try:
+ # Hold the lock across the check + POST + register so that concurrent
+ # node creations on the same compute don't all race past the check and
+ # each POST /projects (the compute-side sync handler then instantiated
+ # the Project N times). Once one creation registers the compute, the
+ # rest see it in the set and return immediately.
+ async with self._create_node_lock:
+ if compute not in self._project_created_on_compute:
+ if compute.id == "local":
+ data = {"name": self._name, "project_id": self._id, "path": self._path}
+ else:
+ data = {"name": self._name, "project_id": self._id}
+ if self._variables:
+ data["variables"] = self._variables
+ await compute.post("/projects", data=data)
+ self._project_created_on_compute.add(compute)
- await node.create(allow_missing_image=allow_missing_image)
+ await node.create(allow_missing_image=allow_missing_image)
+ except BaseException:
+ self.remove_allocated_node_name(node.name)
+ raise
self._nodes[node.id] = node
return node
diff --git a/gns3server/schemas/__init__.py b/gns3server/schemas/__init__.py
index dee12620b..74ddfe4af 100644
--- a/gns3server/schemas/__init__.py
+++ b/gns3server/schemas/__init__.py
@@ -26,29 +26,45 @@ from .controller.computes import (
ComputeUpdate,
ComputeVirtualBoxVM,
ComputeVMwareVM,
+ IdlePC,
)
from .controller.drawings import Drawing
-from .controller.gns3vm import GNS3VM
-from .controller.images import Image, ImageSyncJob, ImageSyncRequest, ImageType, ImageUpload
+from .controller.gns3vm import GNS3VM, GNS3VMEngineInfo, GNS3VMInfo
+from .controller.images import (
+ Image,
+ ImageInstallCreated,
+ ImageInstallResult,
+ ImageInstallSkipped,
+ ImageSyncJob,
+ ImageSyncRequest,
+ ImageType,
+ ImageUpload,
+)
# Controller schemas
from .controller.links import (
EthernetPortInfo,
Link,
+ LinkBatchResult,
LinkCapture,
LinkCreate,
- LinkFilterDefinition,
+ LinkFilterInfo,
LinkFilterParameter,
LinkFilters,
LinkFilterType,
LinkIfaceInfo,
LinkUpdate,
+ LinkWiresharkRestart,
+ Marker,
MarkerCreate,
+ MarkerDefinition,
MarkerDefinitionCreate,
+ MarkerDefinitionWithLinks,
MarkerUpdate,
+ ProjectMarker,
UDPPortInfo,
)
-from .controller.nodes import Node, NodeCapture, NodeCreate, NodeDuplicate, NodeUpdate
+from .controller.nodes import Node, NodeBatchResult, NodeCapture, NodeCreate, NodeDuplicate, NodeUpdate
from .controller.projects import (
NodeFile,
Project,
@@ -56,6 +72,7 @@ from .controller.projects import (
ProjectCreate,
ProjectDuplicate,
ProjectFile,
+ ProjectStats,
ProjectUpdate,
)
from .controller.templates import Template, TemplateCreate, TemplateUpdate, TemplateUsage
@@ -123,12 +140,32 @@ from .compute.vmware_nodes import VMware, VMwareCreate, VMwareUpdate
from .compute.vpcs_nodes import VPCS, VPCSCreate, VPCSUpdate
from .controller.capabilities import Capabilities
from .controller.iou_license import IOULicense
+from .controller.marker_replay import (
+ ReplayFrame,
+ ReplayFrameDetail,
+ ReplayFrames,
+ ReplayFrameSource,
+ ReplaySource,
+ ReplayTimeline,
+ ReplayTreeNode,
+)
from .controller.netmiko import NetmikoDeviceType, NetmikoDeviceTypeList
from .controller.notifications import Notification, NotificationAction
from .controller.pools import Resource, ResourceCreate, ResourcePool, ResourcePoolCreate, ResourcePoolUpdate
-from .controller.rbac import ACE, ACECreate, ACEUpdate, Privilege, Role, RoleCreate, RoleUpdate
+from .controller.rbac import ACE, ACECreate, ACEEndpoint, ACEUpdate, Privilege, Role, RoleCreate, RoleUpdate
from .controller.settings import SettingsResponse, SettingsUpdate, SettingsUpdateResponse
from .controller.snapshots import Snapshot, SnapshotCreate
+from .controller.statistics import (
+ ComputeStatistics,
+ ComputeStatisticsEntry,
+ LinkStatistics,
+ NodeStatistics,
+ ProjectCountStatistics,
+ ServerStatistics,
+ WebWiresharkContainer,
+ WebWiresharkStatistics,
+)
+from .controller.symbols import SymbolDimensions, SymbolInfo
from .controller.templates.cloud_templates import CloudTemplate, CloudTemplateUpdate
from .controller.templates.docker_templates import DockerTemplate, DockerTemplateUpdate
from .controller.templates.dynamips_templates import (
@@ -157,7 +194,7 @@ from .controller.templates.vmware_templates import VMwareTemplate, VMwareTemplat
# Controller template schemas
from .controller.templates.vpcs_templates import VPCSTemplate, VPCSTemplateUpdate
-from .controller.tokens import ApiKeyCreate, RefreshTokenRequest, Token
+from .controller.tokens import ApiKey, ApiKeyCreate, ApiKeyCreated, ApiKeyMessage, RefreshTokenRequest, Token
# Schemas for both controller and compute
from .qemu_disk_image import QemuDiskImageCreate, QemuDiskImageFormat, QemuDiskImageUpdate
@@ -171,12 +208,16 @@ __all__ = [
"UDPNIO",
"VPCS",
"ACECreate",
+ "ACEEndpoint",
"ACEUpdate",
"ATMSwitch",
"ATMSwitchCreate",
"ATMSwitchUpdate",
"AnchorNIO",
+ "ApiKey",
"ApiKeyCreate",
+ "ApiKeyCreated",
+ "ApiKeyMessage",
"Appliance",
"ApplianceVersion",
"ApplianceVersionCreate",
@@ -211,6 +252,8 @@ __all__ = [
"Compute",
"ComputeCreate",
"ComputeDockerImage",
+ "ComputeStatistics",
+ "ComputeStatisticsEntry",
"ComputeUpdate",
"ComputeVMwareVM",
"ComputeVirtualBoxVM",
@@ -242,13 +285,19 @@ __all__ = [
"FrameRelaySwitch",
"FrameRelaySwitchCreate",
"FrameRelaySwitchUpdate",
+ "GNS3VMEngineInfo",
+ "GNS3VMInfo",
"IOUCreate",
"IOULicense",
"IOUStart",
"IOUTemplate",
"IOUTemplateUpdate",
"IOUUpdate",
+ "IdlePC",
"Image",
+ "ImageInstallCreated",
+ "ImageInstallResult",
+ "ImageInstallSkipped",
"ImageSyncJob",
"ImageSyncRequest",
"ImageType",
@@ -261,17 +310,23 @@ __all__ = [
"LLMModelConfigUpdate",
"LLMModelConfigWithSource",
"Link",
+ "LinkBatchResult",
"LinkCapture",
"LinkCreate",
- "LinkFilterDefinition",
+ "LinkFilterInfo",
"LinkFilterParameter",
"LinkFilterType",
"LinkFilters",
"LinkIfaceInfo",
+ "LinkStatistics",
"LinkUpdate",
+ "LinkWiresharkRestart",
"LoggedInUserUpdate",
+ "Marker",
"MarkerCreate",
+ "MarkerDefinition",
"MarkerDefinitionCreate",
+ "MarkerDefinitionWithLinks",
"MarkerRebuild",
"MarkerToggle",
"MarkerUpdate",
@@ -280,10 +335,12 @@ __all__ = [
"NetmikoDeviceType",
"NetmikoDeviceTypeList",
"Node",
+ "NodeBatchResult",
"NodeCapture",
"NodeCreate",
"NodeDuplicate",
"NodeFile",
+ "NodeStatistics",
"NodeUpdate",
"Notification",
"NotificationAction",
@@ -292,9 +349,12 @@ __all__ = [
"Privilege",
"Project",
"ProjectCompression",
+ "ProjectCountStatistics",
"ProjectCreate",
"ProjectDuplicate",
"ProjectFile",
+ "ProjectMarker",
+ "ProjectStats",
"ProjectUpdate",
"Qemu",
"QemuCreate",
@@ -306,6 +366,13 @@ __all__ = [
"QemuUpdate",
"RefreshTokenRequest",
"RenameSession",
+ "ReplayFrame",
+ "ReplayFrameDetail",
+ "ReplayFrameSource",
+ "ReplayFrames",
+ "ReplaySource",
+ "ReplayTimeline",
+ "ReplayTreeNode",
"Resource",
"ResourceCreate",
"ResourcePool",
@@ -315,11 +382,14 @@ __all__ = [
"RoleCreate",
"RoleUpdate",
"ServerConfig",
+ "ServerStatistics",
"SettingsResponse",
"SettingsUpdate",
"SettingsUpdateResponse",
"Snapshot",
"SnapshotCreate",
+ "SymbolDimensions",
+ "SymbolInfo",
"Template",
"TemplateCreate",
"TemplateUpdate",
@@ -347,4 +417,6 @@ __all__ = [
"VirtualBoxTemplate",
"VirtualBoxTemplateUpdate",
"VirtualBoxUpdate",
+ "WebWiresharkContainer",
+ "WebWiresharkStatistics",
]
diff --git a/gns3server/schemas/controller/computes.py b/gns3server/schemas/controller/computes.py
index 5742e4391..229a97660 100644
--- a/gns3server/schemas/controller/computes.py
+++ b/gns3server/schemas/controller/computes.py
@@ -153,3 +153,11 @@ class AutoIdlePC(BaseModel):
model_config = ConfigDict(
json_schema_extra={"example": {"platform": "c7200", "image": "/path/to/c7200_image.bin", "ram": 256}}
)
+
+
+class IdlePC(BaseModel):
+ """
+ Result of an Idle-PC computation.
+ """
+
+ idlepc: Optional[str] = Field(None, description="Idle-PC value, null when none was found")
diff --git a/gns3server/schemas/controller/gns3vm.py b/gns3server/schemas/controller/gns3vm.py
index 71a975d43..2c39a5657 100644
--- a/gns3server/schemas/controller/gns3vm.py
+++ b/gns3server/schemas/controller/gns3vm.py
@@ -55,3 +55,24 @@ class GNS3VM(BaseModel):
vcpus: Optional[int] = Field(None, description="Number of CPUs to allocate for the GNS3 VM")
ram: Optional[int] = Field(None, description="Amount of memory to allocate for the GNS3 VM")
port: Optional[int] = Field(None, gt=0, le=65535)
+
+
+class GNS3VMEngineInfo(BaseModel):
+ """
+ A virtualization engine supported by the GNS3 VM.
+ """
+
+ engine_id: str = Field(..., description="Engine identifier")
+ name: str = Field(..., description="Engine display name")
+ description: str = Field(..., description="Engine description (may contain HTML)")
+ support_when_exit: bool = Field(..., description="The engine supports the 'when exit' action")
+ support_headless: bool = Field(..., description="The engine supports headless mode")
+ support_ram: bool = Field(..., description="The engine supports RAM and vCPUs allocation")
+
+
+class GNS3VMInfo(BaseModel):
+ """
+ A virtual machine available on a virtualization engine.
+ """
+
+ vmname: str = Field(..., description="Virtual machine name")
diff --git a/gns3server/schemas/controller/images.py b/gns3server/schemas/controller/images.py
index 92ba88775..17eb73ad3 100644
--- a/gns3server/schemas/controller/images.py
+++ b/gns3server/schemas/controller/images.py
@@ -16,7 +16,8 @@
from datetime import datetime
from enum import Enum
-from typing import Annotated, Literal, Optional
+from typing import Annotated, List, Literal, Optional
+from uuid import UUID
from pydantic import BaseModel, ConfigDict, Field
@@ -100,3 +101,32 @@ class ImageCompatibility(BaseModel):
class ImageCompatibilityCatalog(BaseModel):
image_sizes: list[int]
has_unknown_sizes: bool
+
+
+class ImageInstallCreated(BaseModel):
+ """
+ A template created from an image by the automatic installation.
+ """
+
+ template_id: UUID
+ name: Optional[str] = None
+ version: Optional[str] = None
+ template_type: Optional[str] = None
+
+
+class ImageInstallSkipped(BaseModel):
+ """
+ An image or template that the automatic installation skipped.
+ """
+
+ name: Optional[str] = None
+ reason: str
+
+
+class ImageInstallResult(BaseModel):
+ """
+ Result of the automatic template installation from images.
+ """
+
+ created: List[ImageInstallCreated]
+ skipped: List[ImageInstallSkipped]
diff --git a/gns3server/schemas/controller/links.py b/gns3server/schemas/controller/links.py
index f1bc2c480..e1ac4993c 100644
--- a/gns3server/schemas/controller/links.py
+++ b/gns3server/schemas/controller/links.py
@@ -20,6 +20,7 @@ from uuid import UUID, uuid4
from pydantic import BaseModel, ConfigDict, Discriminator, Field, field_validator, model_validator
+from ..common import ErrorMessage
from ..update import PartialUpdateModel
from .labels import Label
@@ -138,29 +139,6 @@ class LinkFilters(BaseModel):
)
-class LinkFilterParameter(BaseModel):
- """
- Parameter of a packet filter.
- """
-
- name: str
- type: Literal["int", "text", "str"]
- minimum: Optional[int] = None
- maximum: Optional[int] = None
- unit: Optional[str] = None
-
-
-class LinkFilterDefinition(BaseModel):
- """
- Packet filter available on a link.
- """
-
- type: LinkFilterType
- name: str
- description: str
- parameters: List[LinkFilterParameter]
-
-
class LinkBase(BaseModel):
"""
Link data.
@@ -216,6 +194,16 @@ class Link(LinkBase):
)
+class LinkBatchResult(BaseModel):
+ """
+ Outcome of one link of a batch creation request.
+ """
+
+ status_code: int = Field(..., description="HTTP status code the same request would have returned on its own")
+ link: Optional[Link] = Field(None, description="The created link, set when status_code is 201")
+ error: Optional[ErrorMessage] = Field(None, description="The error, set when the link could not be created")
+
+
class UDPPortInfo(BaseModel):
"""
UDP port information.
@@ -398,3 +386,81 @@ class MarkerDefinitionCreate(BaseModel):
@classmethod
def _both_to_none(cls, v):
return None if v == "both" else v
+
+
+class Marker(BaseModel):
+ """
+ A traffic-insight marker attached to a link.
+ """
+
+ bpf: str = Field(..., description="libpcap BPF expression")
+ tag: Optional[int] = Field(None, description="Correlation id shared by markers replayed together")
+ enabled: bool = Field(True, description="Whether the marker is capturing")
+ color: Optional[str] = Field(None, description="Hex color used by the Web UI")
+ highlight_duration: Optional[int] = Field(None, description="Web UI highlight duration in milliseconds")
+ capture_node_id: Optional[str] = Field(None, description="Node whose uBridge hosts the marker")
+ direction: Optional[str] = Field(None, description="'tx' or 'rx', null for both directions")
+ data_link_type: Optional[str] = Field(None, description="pcap link-layer type of the capture file")
+ inherited_from: Optional[str] = Field(None, description="Project marker definition this marker was copied from")
+
+
+class ProjectMarker(Marker):
+ """
+ A marker in the project-wide aggregation, with the link and node it belongs to.
+ """
+
+ link_id: str
+ node_id: Optional[str] = None
+
+
+class MarkerDefinition(BaseModel):
+ """
+ A project-level marker definition inherited by every link.
+ """
+
+ bpf: str = Field(..., description="libpcap BPF expression")
+ tag: Optional[int] = Field(None, description="Correlation id shared by markers replayed together")
+ direction: Optional[str] = Field(None, description="'tx' or 'rx', null for both directions")
+ color: Optional[str] = Field(None, description="Hex color used by the Web UI")
+ highlight_duration: Optional[int] = Field(None, description="Web UI highlight duration in milliseconds")
+ data_link_type: Optional[str] = Field(None, description="pcap link-layer type of inherited markers")
+ paused: bool = Field(False, description="Whether the inherited markers are paused")
+
+
+class MarkerDefinitionWithLinks(MarkerDefinition):
+ """
+ A project-level marker definition with the links currently carrying a copy of it.
+ """
+
+ link_ids: List[str] = Field(..., description="Links carrying an inherited copy of the definition")
+
+
+class LinkWiresharkRestart(BaseModel):
+ """
+ Result of restarting the Wireshark window of a link capture.
+ """
+
+ status: str
+
+
+class LinkFilterParameter(BaseModel):
+ """
+ A parameter of a packet filter.
+ """
+
+ name: str
+ type: str
+ minimum: Optional[int] = None
+ maximum: Optional[int] = None
+ unit: Optional[str] = None
+
+
+class LinkFilterInfo(BaseModel):
+ """
+ A packet filter that can be applied to a link.
+ """
+
+ type: str = Field(..., description="Filter identifier used as key in the link 'filters' property")
+ name: str
+ description: str
+ parameters: List[LinkFilterParameter]
diff --git a/gns3server/schemas/controller/marker_replay.py b/gns3server/schemas/controller/marker_replay.py
new file mode 100644
index 000000000..a07d31cd3
--- /dev/null
+++ b/gns3server/schemas/controller/marker_replay.py
@@ -0,0 +1,114 @@
+#
+# Copyright (C) 2026 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 .
+
+from typing import List, Optional
+
+from pydantic import BaseModel, ConfigDict, Field
+
+
+class ReplaySource(BaseModel):
+ """
+ A capture source (one marker pcap) of a replay tag.
+ """
+
+ node_id: str
+ link_id: str
+ marker: str
+ data_link_type: str
+ count: int = Field(..., description="Total frames in the capture, unaffected by filters")
+
+
+class ReplayFrame(BaseModel):
+ """
+ One frame of the merged replay timeline.
+ """
+
+ ts: str = Field(..., description="Timestamp string to send back unchanged")
+ len: int = Field(..., description="Captured length in bytes")
+ node_id: str
+ link_id: str
+ marker: str
+ frame_number: int = Field(..., description="1-based frame number in the source pcap")
+ src: Optional[str] = None
+ dst: Optional[str] = None
+ proto: Optional[str] = None
+ info: Optional[str] = None
+ bg: Optional[str] = Field(None, description="Wireshark coloring rule background")
+ fg: Optional[str] = Field(None, description="Wireshark coloring rule foreground")
+
+
+class ReplayTimeline(BaseModel):
+ """
+ Replay timeline of a tag.
+ """
+
+ tag: int
+ start: Optional[str] = Field(None, description="Timestamp of the first matching frame")
+ end: Optional[str] = Field(None, description="Timestamp of the last matching frame")
+ frame_count: int
+ sources: List[ReplaySource]
+ frames: List[ReplayFrame]
+
+
+class ReplayFrames(BaseModel):
+ """
+ Frames of a replay time window.
+ """
+
+ frames: List[ReplayFrame]
+
+
+class ReplayFrameSource(BaseModel):
+ """
+ The source pcap and frame a decoded frame comes from.
+ """
+
+ node_id: str
+ link_id: str
+ marker: str
+ frame_number: int
+
+
+class ReplayTreeNode(BaseModel):
+ """
+ A node of the decoded protocol tree.
+
+ Keys sharkd adds in the future are passed through unchanged.
+ """
+
+ model_config = ConfigDict(extra="allow")
+
+ element: Optional[str] = Field(None, description="Node type")
+ label: Optional[str] = Field(None, description="Display text")
+ name: Optional[str] = Field(None, description="Field name, e.g. 'ip.ttl'")
+ filter_expr: Optional[str] = Field(None, description="Display filter with the value baked in")
+ pos: Optional[int] = Field(None, description="Byte offset for hex highlighting")
+ size: Optional[int] = Field(None, description="Byte length for hex highlighting")
+ expert: Optional[str] = Field(None, description="Expert severity")
+ generated: Optional[bool] = Field(None, description="Generated by Wireshark")
+ children: Optional[List["ReplayTreeNode"]] = Field(None, description="Nested fields")
+
+
+class ReplayFrameDetail(BaseModel):
+ """
+ One decoded frame.
+ """
+
+ ts: str
+ source: ReplayFrameSource
+ field_count: int
+ hex: str = Field(..., description="Raw frame bytes as hex")
+ tree: List[ReplayTreeNode]
diff --git a/gns3server/schemas/controller/nodes.py b/gns3server/schemas/controller/nodes.py
index 6d731fc91..ff1d90d2c 100644
--- a/gns3server/schemas/controller/nodes.py
+++ b/gns3server/schemas/controller/nodes.py
@@ -20,7 +20,7 @@ from uuid import UUID, uuid4
from pydantic import BaseModel, Field
-from ..common import ConsoleType, CustomAdapter, NodeStatus
+from ..common import ConsoleType, CustomAdapter, ErrorMessage, NodeStatus
from ..update import PartialUpdateModel
from .labels import Label
@@ -168,6 +168,10 @@ class NodeCreate(NodeBase):
name: str
node_type: NodeType
node_id: UUID = Field(default_factory=uuid4)
+ strict_names: bool = Field(
+ False,
+ description="Return a 409 error instead of renaming the node when the requested name is already used",
+ )
class NodeUpdate(PartialUpdateModel, NodeBase):
@@ -205,6 +209,16 @@ class Node(NodeBase):
)
+class NodeBatchResult(BaseModel):
+ """
+ Outcome of one node of a batch creation request.
+ """
+
+ status_code: int = Field(..., description="HTTP status code the same request would have returned on its own")
+ node: Optional[Node] = Field(None, description="The created node, set when status_code is 201")
+ error: Optional[ErrorMessage] = Field(None, description="The error, set when the node could not be created")
+
+
class NodeDuplicate(BaseModel):
"""
Data to duplicate a node.
diff --git a/gns3server/schemas/controller/projects.py b/gns3server/schemas/controller/projects.py
index 1b84b5586..138fd71ea 100644
--- a/gns3server/schemas/controller/projects.py
+++ b/gns3server/schemas/controller/projects.py
@@ -127,3 +127,15 @@ class ProjectCompression(str, Enum):
bzip2 = "bzip2"
lzma = "lzma"
zstd = "zstd"
+
+
+class ProjectStats(BaseModel):
+ """
+ Project object counts.
+ """
+
+ nodes: int
+ links: int
+ drawings: int
+ snapshots: int
+ markers: int
diff --git a/gns3server/schemas/controller/rbac.py b/gns3server/schemas/controller/rbac.py
index e02a46180..60ff78e96 100644
--- a/gns3server/schemas/controller/rbac.py
+++ b/gns3server/schemas/controller/rbac.py
@@ -109,3 +109,13 @@ class Role(DateTimeModelMixin, RoleBase):
is_builtin: bool
privileges: List[Privilege]
model_config = ConfigDict(from_attributes=True)
+
+
+class ACEEndpoint(BaseModel):
+ """
+ An endpoint that can be used in an ACL entry.
+ """
+
+ endpoint: str = Field(..., description="Endpoint path to use as the ACE path")
+ name: str = Field(..., description="Human readable name of the endpoint")
+ endpoint_type: str = Field(..., description="Kind of resource the endpoint refers to")
diff --git a/gns3server/schemas/controller/statistics.py b/gns3server/schemas/controller/statistics.py
new file mode 100644
index 000000000..14ec8bc6d
--- /dev/null
+++ b/gns3server/schemas/controller/statistics.py
@@ -0,0 +1,131 @@
+#
+# Copyright (C) 2026 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 .
+
+from typing import Dict, List, Optional, Union
+
+from pydantic import BaseModel, Field
+
+
+class ComputeStatistics(BaseModel):
+ """
+ Resource usage reported by a compute.
+
+ Fields come from the compute itself, so a compute running another build
+ may omit some of them.
+ """
+
+ memory_total: Optional[int] = None
+ memory_free: Optional[int] = None
+ memory_used: Optional[int] = None
+ swap_total: Optional[int] = None
+ swap_free: Optional[int] = None
+ swap_used: Optional[int] = None
+ cpu_usage_percent: Optional[int] = None
+ cpu_count: Optional[int] = None
+ cpu_count_physical: Optional[int] = None
+ cpu_model: Optional[str] = None
+ memory_usage_percent: Optional[int] = None
+ swap_usage_percent: Optional[int] = None
+ disk_usage_percent: Optional[int] = None
+ disk_total: Optional[int] = None
+ disk_used: Optional[int] = None
+ disk_free: Optional[int] = None
+ load_average: Optional[List[float]] = None
+ load_average_percent: Optional[List[float]] = None
+
+
+class ComputeStatisticsEntry(BaseModel):
+ """
+ Statistics of one compute.
+ """
+
+ compute_id: str
+ compute_name: str
+ statistics: ComputeStatistics
+
+
+class ProjectCountStatistics(BaseModel):
+ """
+ Project counts by status.
+ """
+
+ total: int
+ opened: int
+ closed: int
+
+
+class NodeStatistics(BaseModel):
+ """
+ Node counts across all projects.
+ """
+
+ total: int
+ open_project_nodes: int
+ closed_project_nodes: int
+ by_type: Dict[str, int] = Field(..., description="Node count by node type")
+ by_status: Dict[str, int] = Field(..., description="Node count by status, open projects only")
+
+
+class LinkStatistics(BaseModel):
+ """
+ Link counts across all projects.
+ """
+
+ total: int
+ capturing: int
+
+
+class WebWiresharkContainer(BaseModel):
+ """
+ A Web Wireshark container.
+ """
+
+ project_id: str
+ project_name: str
+ container_id: str
+ status: str
+ running: bool
+ active_sessions: Optional[int] = None
+ memory_limit: Optional[str] = None
+ cpu_limit: Optional[str] = None
+ pids_limit: Optional[Union[int, str]] = Field(None, description="Process limit, or 'unlimited'")
+ memory: Optional[str] = Field(None, description="Current memory usage")
+ cpu: Optional[str] = Field(None, description="Current CPU usage")
+ pids: Optional[int] = Field(None, description="Current process count")
+
+
+class WebWiresharkStatistics(BaseModel):
+ """
+ Web Wireshark container statistics across all opened projects.
+ """
+
+ total_containers: int
+ running_containers: int
+ active_sessions: int
+ containers: List[WebWiresharkContainer]
+
+
+class ServerStatistics(BaseModel):
+ """
+ Server statistics.
+ """
+
+ uptime_seconds: int
+ computes: List[ComputeStatisticsEntry]
+ projects: ProjectCountStatistics
+ nodes: NodeStatistics
+ links: LinkStatistics
+ webwireshark: WebWiresharkStatistics
diff --git a/gns3server/schemas/controller/symbols.py b/gns3server/schemas/controller/symbols.py
new file mode 100644
index 000000000..75f9e9f31
--- /dev/null
+++ b/gns3server/schemas/controller/symbols.py
@@ -0,0 +1,37 @@
+#
+# Copyright (C) 2026 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 .
+
+from pydantic import BaseModel
+
+
+class SymbolInfo(BaseModel):
+ """
+ A symbol available on the controller.
+ """
+
+ symbol_id: str
+ filename: str
+ theme: str
+ builtin: bool
+
+
+class SymbolDimensions(BaseModel):
+ """
+ Symbol dimensions in pixels.
+ """
+
+ width: int
+ height: int
diff --git a/gns3server/schemas/controller/tokens.py b/gns3server/schemas/controller/tokens.py
index 1ecd6545a..7b8841b55 100644
--- a/gns3server/schemas/controller/tokens.py
+++ b/gns3server/schemas/controller/tokens.py
@@ -15,6 +15,7 @@
# along with this program. If not, see .
from typing import Optional
+from uuid import UUID
from pydantic import BaseModel
@@ -43,3 +44,30 @@ class ApiKeyCreate(BaseModel):
"""Schema for creating a new API key."""
name: str
+
+
+class ApiKey(BaseModel):
+ """API key metadata. The secret is never returned after creation."""
+
+ api_key_id: UUID
+ name: str
+ key_prefix: str
+ created_at: Optional[str] = None
+ last_used_at: Optional[str] = None
+ revoked: bool
+
+
+class ApiKeyCreated(BaseModel):
+ """A newly created API key. The full key is returned only once."""
+
+ api_key_id: UUID
+ api_key: str
+ name: str
+ key_prefix: str
+ created_at: Optional[str] = None
+
+
+class ApiKeyMessage(BaseModel):
+ """Confirmation message returned when an API key is revoked or restored."""
+
+ message: str
diff --git a/tests/api/routes/controller/test_appliances.py b/tests/api/routes/controller/test_appliances.py
index 193557e35..e2b853f5f 100644
--- a/tests/api/routes/controller/test_appliances.py
+++ b/tests/api/routes/controller/test_appliances.py
@@ -105,7 +105,7 @@ class TestApplianceRoutes:
async def test_add_v8_version_appliance(self, app: FastAPI, client: AsyncClient) -> None:
appliance_id = "1cfdf900-7c30-4cb7-8f03-3f61d2581633" # Empty VM appliance
- new_version = {"name": "v8-99G", "category": "guest", "images": {"hda_disk_image": "empty99G.qcow2"}}
+ new_version = {"name": "v8-99G", "images": {"hda_disk_image": "empty99G.qcow2"}}
response = await client.post(
app.url_path_for("add_appliance_version", appliance_id=appliance_id), json=new_version
)
diff --git a/tests/api/routes/controller/test_links.py b/tests/api/routes/controller/test_links.py
index 68f54f037..14e5b9699 100644
--- a/tests/api/routes/controller/test_links.py
+++ b/tests/api/routes/controller/test_links.py
@@ -81,6 +81,45 @@ class TestLinkRoutes:
assert len(project.links) == 1
assert list(project.links.values())[0].filters == filters
+ async def test_create_links_batch(
+ self, app: FastAPI, client: AsyncClient, project: Project, nodes: Tuple[Node, Node]
+ ) -> None:
+
+ node1, node2 = nodes
+ valid = {
+ "nodes": [
+ {"node_id": node1.id, "adapter_number": 0, "port_number": 3},
+ {"node_id": node2.id, "adapter_number": 2, "port_number": 4},
+ ]
+ }
+ same_node = {
+ "nodes": [
+ {"node_id": node1.id, "adapter_number": 0, "port_number": 3},
+ {"node_id": node1.id, "adapter_number": 0, "port_number": 3},
+ ]
+ }
+ unknown_node = {
+ "nodes": [
+ {"node_id": str(uuid.uuid4()), "adapter_number": 0, "port_number": 3},
+ {"node_id": node2.id, "adapter_number": 2, "port_number": 4},
+ ]
+ }
+
+ with asyncio_patch("gns3server.controller.udp_link.UDPLink.create"):
+ response = await client.post(
+ app.url_path_for("create_links", project_id=project.id), json=[same_node, unknown_node, valid]
+ )
+
+ assert response.status_code == status.HTTP_200_OK
+ results = response.json()
+ assert [r["status_code"] for r in results] == [409, 404, 201]
+ assert results[0]["link"] is None
+ assert results[0]["error"]["message"]
+ assert results[1]["error"]["message"]
+ assert results[2]["error"] is None
+ assert results[2]["link"]["link_id"] in project.links
+ assert len(project.links) == 1
+
async def test_create_link_failure(
self, app: FastAPI, client: AsyncClient, compute: Compute, project: Project
) -> None:
@@ -609,12 +648,12 @@ class TestLinkRoutes:
assert "LinkFilters" not in schemas
filters_property = schemas["Link"]["properties"]["filters"]
assert filters_property["anyOf"][0] == {"additionalProperties": True, "type": "object"}
- assert schemas["LinkFilterDefinition"]["properties"]["parameters"]["items"] == {
+ assert schemas["LinkFilterInfo"]["properties"]["parameters"]["items"] == {
"$ref": "#/components/schemas/LinkFilterParameter"
}
path = "/v3/projects/{project_id}/links/{link_id}/available_filters"
responses = app.openapi()["paths"][path]["get"]["responses"]["200"]["content"]["application/json"]["schema"]
- assert responses["items"] == {"$ref": "#/components/schemas/LinkFilterDefinition"}
+ assert responses["items"] == {"$ref": "#/components/schemas/LinkFilterInfo"}
async def test_get_udp_interface(self, app: FastAPI, client: AsyncClient, project: Project) -> None:
"""
diff --git a/tests/api/routes/controller/test_markers.py b/tests/api/routes/controller/test_markers.py
index b53ec8533..bc4fecf30 100644
--- a/tests/api/routes/controller/test_markers.py
+++ b/tests/api/routes/controller/test_markers.py
@@ -19,6 +19,8 @@ HTTP-route tests for the traffic-insight marker endpoints: per-link markers,
project-level definitions, and the project-wide aggregation view.
"""
+from unittest.mock import DEFAULT
+
import pytest
from fastapi import FastAPI, status
from httpx import AsyncClient
@@ -43,6 +45,25 @@ def _inherited(link, name="arp"):
}
+def _record_marker(link):
+ """Side effect for a mocked ``start_marker`` that stores the marker like the real one does."""
+
+ def side_effect(*, name, bpf, tag=None, color=None, highlight_duration=None, direction=None, **kwargs):
+ link._markers[name] = {
+ "bpf": bpf,
+ "tag": tag,
+ "enabled": True,
+ "color": color,
+ "highlight_duration": highlight_duration,
+ "capture_node_id": "node-id",
+ "direction": direction,
+ "data_link_type": kwargs.get("data_link_type", "DLT_EN10MB"),
+ }
+ return DEFAULT
+
+ return side_effect
+
+
class TestMarkerRoutes:
# -----------------------------------------------------------------------
# Per-link markers
@@ -53,13 +74,25 @@ class TestMarkerRoutes:
link = UDPLink(project)
project._links = {link.id: link}
- with asyncio_patch("gns3server.controller.udp_link.UDPLink.start_marker") as mock:
+ with asyncio_patch(
+ "gns3server.controller.udp_link.UDPLink.start_marker", side_effect=_record_marker(link)
+ ) as mock:
response = await client.post(
app.url_path_for("create_marker", project_id=project.id, link_id=link.id),
json={"bpf": "icmp", "tag": 3, "color": "#ff5722", "highlight_duration": 800},
)
assert response.status_code == status.HTTP_201_CREATED
+ assert response.json() == {
+ "bpf": "icmp",
+ "tag": 3,
+ "enabled": True,
+ "color": "#ff5722",
+ "highlight_duration": 800,
+ "capture_node_id": "node-id",
+ "direction": None,
+ "data_link_type": "DLT_EN10MB",
+ }
mock.assert_called_once()
_, kwargs = mock.call_args
assert kwargs["bpf"] == "icmp"
@@ -73,12 +106,15 @@ class TestMarkerRoutes:
link = UDPLink(project)
project._links = {link.id: link}
- with asyncio_patch("gns3server.controller.udp_link.UDPLink.start_marker") as mock:
+ with asyncio_patch(
+ "gns3server.controller.udp_link.UDPLink.start_marker", side_effect=_record_marker(link)
+ ) as mock:
response = await client.post(
app.url_path_for("create_marker", project_id=project.id, link_id=link.id),
json={"name": "web", "bpf": "tcp port 80"},
)
assert response.status_code == status.HTTP_201_CREATED
+ assert response.json()["bpf"] == "tcp port 80"
_, kwargs = mock.call_args
assert kwargs["name"] == "web"
@@ -141,6 +177,16 @@ class TestMarkerRoutes:
async def test_update_marker(self, app: FastAPI, client: AsyncClient, project: Project) -> None:
link = UDPLink(project)
+ link._markers["web"] = {
+ "bpf": "udp port 53",
+ "tag": None,
+ "enabled": True,
+ "color": None,
+ "highlight_duration": 1500,
+ "capture_node_id": "node-id",
+ "direction": None,
+ "data_link_type": "DLT_EN10MB",
+ }
project._links = {link.id: link}
with asyncio_patch("gns3server.controller.udp_link.UDPLink.update_marker") as mock:
@@ -149,6 +195,7 @@ class TestMarkerRoutes:
json={"bpf": "udp port 53", "highlight_duration": 1500},
)
assert response.status_code == status.HTTP_200_OK
+ assert response.json() == link._markers["web"]
_, kwargs = mock.call_args
assert kwargs["bpf"] == "udp port 53"
assert kwargs["highlight_duration"] == 1500
@@ -195,12 +242,27 @@ class TestMarkerRoutes:
async def test_create_marker_definition(self, app: FastAPI, client: AsyncClient, project: Project) -> None:
- with asyncio_patch("gns3server.controller.project.Project.create_marker_definition") as mock:
+ def store_definition(*, name, bpf, highlight_duration=None, **kwargs):
+ project._marker_definitions[name] = {
+ "bpf": bpf,
+ "tag": None,
+ "direction": None,
+ "color": None,
+ "highlight_duration": highlight_duration,
+ "data_link_type": "DLT_EN10MB",
+ "paused": False,
+ }
+ return DEFAULT
+
+ with asyncio_patch(
+ "gns3server.controller.project.Project.create_marker_definition", side_effect=store_definition
+ ) as mock:
response = await client.post(
app.url_path_for("create_marker_definition", project_id=project.id),
json={"name": "arp", "bpf": "arp", "highlight_duration": 1200},
)
assert response.status_code == status.HTTP_201_CREATED
+ assert response.json() == project._marker_definitions["arp"]
_, kwargs = mock.call_args
assert kwargs["name"] == "arp"
assert kwargs["bpf"] == "arp"
@@ -220,12 +282,15 @@ class TestMarkerRoutes:
async def test_update_marker_definition(self, app: FastAPI, client: AsyncClient, project: Project) -> None:
+ project._marker_definitions["arp"] = {"bpf": "arp or rarp", "tag": None, "highlight_duration": 900}
+
with asyncio_patch("gns3server.controller.project.Project.update_marker_definition") as mock:
response = await client.put(
app.url_path_for("update_marker_definition", project_id=project.id, def_name="arp"),
json={"bpf": "arp or rarp", "highlight_duration": 900},
)
assert response.status_code == status.HTTP_200_OK
+ assert response.json() == {"bpf": "arp or rarp", "tag": None, "highlight_duration": 900}
_, kwargs = mock.call_args
assert kwargs["bpf"] == "arp or rarp"
assert kwargs["highlight_duration"] == 900
diff --git a/tests/api/routes/controller/test_nodes.py b/tests/api/routes/controller/test_nodes.py
index 458bfb309..be05b9cf3 100644
--- a/tests/api/routes/controller/test_nodes.py
+++ b/tests/api/routes/controller/test_nodes.py
@@ -34,7 +34,7 @@ from pydantic import SecretStr
from gns3server.api.routes.controller.nodes import vnc_console, ws_console
from gns3server.config import Config
from gns3server.controller.compute import Compute
-from gns3server.controller.controller_error import ComputeError
+from gns3server.controller.controller_error import ComputeError, ControllerError
from gns3server.controller.node import Node
from gns3server.controller.project import Project
from gns3server.services import auth_service
@@ -73,6 +73,103 @@ class TestNodeRoutes:
assert response.json()["name"] == "test"
assert "name" not in response.json()["properties"]
+ async def test_create_node_taken_name_is_renamed(
+ self, app: FastAPI, client: AsyncClient, project: Project, compute: Compute
+ ) -> None:
+
+ response = MagicMock()
+ response.json = {"console": 2048}
+ compute.post = AsyncioMagicMock(return_value=response)
+ payload = {"name": "R1", "node_type": "vpcs", "compute_id": "example.com"}
+
+ first = await client.post(app.url_path_for("create_node", project_id=project.id), json=payload)
+ second = await client.post(app.url_path_for("create_node", project_id=project.id), json=payload)
+
+ assert first.json()["name"] == "R1"
+ assert second.status_code == status.HTTP_201_CREATED
+ assert second.json()["name"] == "R2"
+
+ async def test_create_node_strict_names(
+ self, app: FastAPI, client: AsyncClient, project: Project, compute: Compute
+ ) -> None:
+
+ response = MagicMock()
+ response.json = {"console": 2048}
+ compute.post = AsyncioMagicMock(return_value=response)
+ payload = {"name": "R1", "node_type": "vpcs", "compute_id": "example.com", "strict_names": True}
+
+ first = await client.post(app.url_path_for("create_node", project_id=project.id), json=payload)
+ second = await client.post(app.url_path_for("create_node", project_id=project.id), json=payload)
+
+ assert first.status_code == status.HTTP_201_CREATED
+ assert second.status_code == status.HTTP_409_CONFLICT
+ assert "R1" in second.json()["message"]
+ assert len(project.nodes) == 1
+
+ async def test_create_nodes_batch(
+ self, app: FastAPI, client: AsyncClient, project: Project, compute: Compute
+ ) -> None:
+
+ response = MagicMock()
+ response.json = {"console": 2048}
+ compute.post = AsyncioMagicMock(return_value=response)
+ nodes = [{"name": "R{0}", "node_type": "vpcs", "compute_id": "example.com"} for _ in range(3)]
+
+ response = await client.post(app.url_path_for("create_nodes", project_id=project.id), json=nodes)
+
+ assert response.status_code == status.HTTP_200_OK
+ results = response.json()
+ assert [r["status_code"] for r in results] == [201, 201, 201]
+ assert [r["node"]["name"] for r in results] == ["R1", "R2", "R3"]
+ assert all(r["error"] is None for r in results)
+ assert len(project.nodes) == 3
+
+ async def test_create_nodes_batch_item_failures_keep_order(
+ self, app: FastAPI, client: AsyncClient, project: Project, compute: Compute
+ ) -> None:
+
+ response = MagicMock()
+ response.json = {"console": 2048}
+ compute.post = AsyncioMagicMock(return_value=response)
+ nodes = [
+ {"name": "R1", "node_type": "vpcs", "compute_id": "example.com", "strict_names": True},
+ {"name": "R1", "node_type": "vpcs", "compute_id": "example.com", "strict_names": True},
+ {"name": "R1", "node_type": "vpcs", "compute_id": "unknown"},
+ {"name": "R1", "node_type": "vpcs", "compute_id": "example.com"},
+ ]
+
+ response = await client.post(app.url_path_for("create_nodes", project_id=project.id), json=nodes)
+
+ assert response.status_code == status.HTTP_200_OK
+ results = response.json()
+ assert [r["status_code"] for r in results] == [201, 409, 404, 201]
+ assert results[0]["node"]["name"] == "R1"
+ assert results[1]["node"] is None
+ assert "R1" in results[1]["error"]["message"]
+ assert results[2]["error"]["message"]
+ assert results[3]["node"]["name"] == "R2"
+ assert len(project.nodes) == 2
+
+ async def test_create_nodes_batch_failed_node_releases_name(
+ self, app: FastAPI, client: AsyncClient, project: Project, compute: Compute
+ ) -> None:
+
+ compute.post = AsyncioMagicMock(side_effect=ControllerError("compute failure"))
+ nodes = [{"name": "R1", "node_type": "vpcs", "compute_id": "example.com", "strict_names": True}]
+
+ response = await client.post(app.url_path_for("create_nodes", project_id=project.id), json=nodes)
+
+ assert response.json()[0]["status_code"] == status.HTTP_409_CONFLICT
+ assert len(project.nodes) == 0
+
+ response_ok = MagicMock()
+ response_ok.json = {"console": 2048}
+ compute.post = AsyncioMagicMock(return_value=response_ok)
+ response = await client.post(app.url_path_for("create_nodes", project_id=project.id), json=nodes)
+
+ assert response.json()[0]["status_code"] == status.HTTP_201_CREATED
+ assert response.json()[0]["node"]["name"] == "R1"
+
async def test_list_node(self, app: FastAPI, client: AsyncClient, project: Project, compute: Compute) -> None:
response = MagicMock()
diff --git a/tests/api/routes/controller/test_response_models.py b/tests/api/routes/controller/test_response_models.py
new file mode 100644
index 000000000..9ea0f6dda
--- /dev/null
+++ b/tests/api/routes/controller/test_response_models.py
@@ -0,0 +1,464 @@
+#
+# Copyright (C) 2026 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 .
+
+"""
+Runtime JSON shape of the endpoints that used to return untyped objects, and
+the OpenAPI contract that now describes them.
+"""
+
+import uuid
+from unittest.mock import AsyncMock, MagicMock, patch
+
+import pytest
+from fastapi import FastAPI, status
+from httpx import AsyncClient
+
+from gns3server.controller import Controller, marker_replay
+from gns3server.controller.link import Link
+from gns3server.controller.project import Project
+from gns3server.controller.udp_link import UDPLink
+from tests.controller.test_marker_replay import _write_pcap
+
+pytestmark = pytest.mark.asyncio
+
+TYPED_OPERATIONS = [
+ ("post", "/v3/access/api-keys"),
+ ("get", "/v3/access/api-keys"),
+ ("post", "/v3/access/api-keys/{api_key_id}/revoke"),
+ ("post", "/v3/access/api-keys/{api_key_id}/restore"),
+ ("get", "/v3/statistics"),
+ ("get", "/v3/projects/{project_id}/stats"),
+ ("post", "/v3/images/install"),
+ ("post", "/v3/appliances/{appliance_id}/version"),
+ ("get", "/v3/projects/{project_id}/markers"),
+ ("get", "/v3/projects/{project_id}/marker-definitions"),
+ ("post", "/v3/projects/{project_id}/marker-definitions"),
+ ("put", "/v3/projects/{project_id}/marker-definitions/{def_name}"),
+ ("get", "/v3/projects/{project_id}/markers/tags/{tag}/replay/range"),
+ ("get", "/v3/projects/{project_id}/markers/tags/{tag}/replay/frames"),
+ ("get", "/v3/projects/{project_id}/markers/tags/{tag}/replay/frame/detail"),
+ ("get", "/v3/projects/{project_id}/links/{link_id}/markers"),
+ ("post", "/v3/projects/{project_id}/links/{link_id}/markers"),
+ ("put", "/v3/projects/{project_id}/links/{link_id}/markers/{marker_name}"),
+ ("get", "/v3/symbols"),
+ ("get", "/v3/symbols/{symbol_id}/dimensions"),
+ ("get", "/v3/symbols/default_symbols"),
+ ("get", "/v3/projects/{project_id}/links/{link_id}/available_filters"),
+ ("post", "/v3/projects/{project_id}/links/{link_id}/capture/wireshark/restart"),
+ ("get", "/v3/projects/{project_id}/nodes/{node_id}/dynamips/auto_idlepc"),
+ ("post", "/v3/computes/{compute_id}/dynamips/auto_idlepc"),
+ ("get", "/v3/gns3vm/engines"),
+ ("get", "/v3/gns3vm/engines/{engine}/vms"),
+ ("get", "/v3/access/acl/endpoints"),
+]
+
+
+def _is_untyped(schema: dict) -> bool:
+ if not schema:
+ return True
+ if "$ref" in schema:
+ return False
+ if schema.get("type") == "array":
+ return _is_untyped(schema.get("items", {}))
+ if schema.get("type") == "object":
+ extra = schema.get("additionalProperties")
+ if "properties" in schema:
+ return False
+ return extra in (None, True, {})
+ for key in ("anyOf", "oneOf"):
+ if key in schema:
+ return any(_is_untyped(s) for s in schema[key] if s.get("type") != "null")
+ return False
+
+
+class TestOpenAPIContract:
+ async def test_no_untyped_success_response(self, app: FastAPI, client: AsyncClient) -> None:
+
+ response = await client.get("/openapi.json")
+ assert response.status_code == status.HTTP_200_OK
+ paths = response.json()["paths"]
+
+ untyped = []
+ for method, path in TYPED_OPERATIONS:
+ operation = paths[path][method]
+ success = [r for code, r in operation["responses"].items() if code.startswith("2") and code != "204"]
+ assert success, f"{method.upper()} {path} declares no success response"
+ for r in success:
+ schema = r["content"]["application/json"]["schema"]
+ if _is_untyped(schema):
+ untyped.append(f"{method.upper()} {path}")
+ assert not untyped
+
+
+class TestApiKeys:
+ async def _create(self, app: FastAPI, client: AsyncClient, name: str = "ci") -> dict:
+
+ response = await client.post(app.url_path_for("create_api_key"), json={"name": name})
+ assert response.status_code == status.HTTP_201_CREATED
+ return response.json()
+
+ async def test_create(self, app: FastAPI, client: AsyncClient) -> None:
+
+ body = await self._create(app, client, "created")
+ assert set(body) == {"api_key_id", "api_key", "name", "key_prefix", "created_at"}
+ assert body["name"] == "created"
+ assert body["api_key"].startswith(f"gns3_{body['api_key_id']}_")
+ assert body["key_prefix"] == body["api_key"][:8]
+ assert isinstance(body["created_at"], str)
+ uuid.UUID(body["api_key_id"])
+
+ async def test_list(self, app: FastAPI, client: AsyncClient) -> None:
+
+ created = await self._create(app, client, "listed")
+ response = await client.get(app.url_path_for("list_api_keys"))
+ assert response.status_code == status.HTTP_200_OK
+ entry = next(k for k in response.json() if k["api_key_id"] == created["api_key_id"])
+ assert set(entry) == {"api_key_id", "name", "key_prefix", "created_at", "last_used_at", "revoked"}
+ assert entry["name"] == "listed"
+ assert entry["last_used_at"] is None
+ assert entry["revoked"] is False
+ assert entry["created_at"] == created["created_at"]
+
+ async def test_revoke_and_restore(self, app: FastAPI, client: AsyncClient) -> None:
+
+ created = await self._create(app, client, "toggled")
+ key_id = created["api_key_id"]
+
+ response = await client.post(app.url_path_for("revoke_api_key", api_key_id=key_id))
+ assert response.status_code == status.HTTP_200_OK
+ assert response.json() == {"message": "API key 'toggled' revoked"}
+ keys = (await client.get(app.url_path_for("list_api_keys"))).json()
+ assert next(k for k in keys if k["api_key_id"] == key_id)["revoked"] is True
+
+ response = await client.post(app.url_path_for("restore_api_key", api_key_id=key_id))
+ assert response.status_code == status.HTTP_200_OK
+ assert response.json() == {"message": "API key 'toggled' restored"}
+ keys = (await client.get(app.url_path_for("list_api_keys"))).json()
+ assert next(k for k in keys if k["api_key_id"] == key_id)["revoked"] is False
+
+ async def test_revoke_unknown_key(self, app: FastAPI, client: AsyncClient) -> None:
+
+ response = await client.post(app.url_path_for("revoke_api_key", api_key_id=str(uuid.uuid4())))
+ assert response.status_code == status.HTTP_404_NOT_FOUND
+
+
+COMPUTE_STATISTICS = {
+ "memory_total": 8000,
+ "memory_free": 4000,
+ "memory_used": 4000,
+ "swap_total": 1000,
+ "swap_free": 900,
+ "swap_used": 100,
+ "cpu_usage_percent": 12,
+ "cpu_count": 4,
+ "cpu_count_physical": None,
+ "cpu_model": "Test CPU",
+ "memory_usage_percent": 50,
+ "swap_usage_percent": 10,
+ "disk_usage_percent": 30,
+ "disk_total": 100000,
+ "disk_used": 30000,
+ "disk_free": 70000,
+ "load_average": [0.5, 0.4, 0.3],
+ "load_average_percent": [12.5, 10.0, 7.5],
+}
+
+
+class TestStatistics:
+ async def test_statistics_shape(self, app: FastAPI, client: AsyncClient, project: Project) -> None:
+
+ webwireshark = {
+ "total_containers": 1,
+ "running_containers": 1,
+ "active_sessions": 0,
+ "containers": [
+ {
+ "project_id": project.id,
+ "project_name": project.name,
+ "container_id": "abcdef123456",
+ "status": "running",
+ "running": True,
+ "active_sessions": 0,
+ "memory_limit": "unlimited",
+ "cpu_limit": "unlimited",
+ "pids_limit": "unlimited",
+ "memory": "10MiB / 1GiB",
+ "cpu": "0.5%",
+ "pids": 3,
+ },
+ {
+ "project_id": project.id,
+ "project_name": project.name,
+ "container_id": "123456abcdef",
+ "status": "exited",
+ "running": False,
+ },
+ ],
+ }
+ with patch(
+ "gns3server.api.routes.controller.controller.collect_webwireshark_stats",
+ new=AsyncMock(return_value=webwireshark),
+ ):
+ response = await client.get(app.url_path_for("statistics"))
+ assert response.status_code == status.HTTP_200_OK
+ body = response.json()
+ assert set(body) == {"uptime_seconds", "computes", "projects", "nodes", "links", "webwireshark"}
+ assert isinstance(body["uptime_seconds"], int)
+ assert body["projects"] == {"total": 1, "opened": 1, "closed": 0}
+ assert body["nodes"] == {
+ "total": 0,
+ "open_project_nodes": 0,
+ "closed_project_nodes": 0,
+ "by_type": {},
+ "by_status": {},
+ }
+ assert body["links"] == {"total": 0, "capturing": 0}
+ assert body["webwireshark"] == webwireshark
+
+ async def test_statistics_compute_entry(
+ self, app: FastAPI, client: AsyncClient, controller: Controller, project: Project
+ ) -> None:
+
+ compute = MagicMock()
+ compute.id = "local"
+ compute.name = "local"
+ reply = MagicMock()
+ reply.json = dict(COMPUTE_STATISTICS)
+ compute.get = AsyncMock(return_value=reply)
+ controller._computes = {"local": compute}
+
+ response = await client.get(app.url_path_for("statistics"))
+ assert response.status_code == status.HTTP_200_OK
+ assert response.json()["computes"] == [
+ {"compute_id": "local", "compute_name": "local", "statistics": COMPUTE_STATISTICS}
+ ]
+
+ async def test_statistics_compute_missing_fields_stay_absent(
+ self, app: FastAPI, client: AsyncClient, controller: Controller, project: Project
+ ) -> None:
+
+ compute = MagicMock()
+ compute.id = "old"
+ compute.name = "old"
+ reply = MagicMock()
+ reply.json = {"memory_total": 8000, "cpu_count": 2}
+ compute.get = AsyncMock(return_value=reply)
+ controller._computes = {"old": compute}
+
+ response = await client.get(app.url_path_for("statistics"))
+ assert response.status_code == status.HTTP_200_OK
+ assert response.json()["computes"][0]["statistics"] == {"memory_total": 8000, "cpu_count": 2}
+
+ async def test_project_stats(self, app: FastAPI, client: AsyncClient, project: Project) -> None:
+
+ link = UDPLink(project)
+ link._markers["a"] = {"bpf": "icmp"}
+ link._markers["b"] = {"bpf": "arp"}
+ project._links = {link.id: link}
+
+ response = await client.get(app.url_path_for("get_project_stats", project_id=project.id))
+ assert response.status_code == status.HTTP_200_OK
+ assert response.json() == {"nodes": 0, "links": 1, "drawings": 0, "snapshots": 0, "markers": 2}
+
+
+class TestSymbols:
+ async def test_list_shape(self, app: FastAPI, client: AsyncClient) -> None:
+
+ response = await client.get(app.url_path_for("get_symbols"))
+ assert response.status_code == status.HTTP_200_OK
+ symbols = response.json()
+ assert symbols
+ for symbol in symbols:
+ assert set(symbol) == {"symbol_id", "filename", "theme", "builtin"}
+ assert isinstance(symbol["builtin"], bool)
+
+ async def test_default_symbols(self, app: FastAPI, client: AsyncClient) -> None:
+
+ response = await client.get(app.url_path_for("get_default_symbols"))
+ assert response.status_code == status.HTTP_200_OK
+ themes = response.json()
+ assert themes["Classic"]["firewall"] == ":/symbols/classic/firewall.svg"
+ assert themes == Controller.instance().symbols.default_symbols()
+
+ async def test_dimensions(self, app: FastAPI, client: AsyncClient) -> None:
+
+ response = await client.get(
+ app.url_path_for("get_symbol_dimensions", symbol_id=":/symbols/classic/firewall.svg")
+ )
+ assert response.status_code == status.HTTP_200_OK
+ body = response.json()
+ assert set(body) == {"width", "height"}
+ assert isinstance(body["width"], int)
+ assert isinstance(body["height"], int)
+
+
+class TestLinkRoutes:
+ async def test_wireshark_restart(self, app: FastAPI, client: AsyncClient, project: Project) -> None:
+
+ link = Link(project)
+ project._links = {link.id: link}
+ with patch("gns3server.controller.link.Link._restart_web_wireshark", new=AsyncMock()):
+ response = await client.post(app.url_path_for("restart_wireshark", project_id=project.id, link_id=link.id))
+ assert response.status_code == status.HTTP_200_OK
+ assert response.json() == {"status": "restarted"}
+
+ async def test_available_filters_keep_optional_parameter_keys_absent(
+ self, app: FastAPI, client: AsyncClient, project: Project
+ ) -> None:
+
+ link = Link(project)
+ project._links = {link.id: link}
+ with patch.object(Link, "_get_filter_node", return_value=object()):
+ response = await client.get(app.url_path_for("get_filters", project_id=project.id, link_id=link.id))
+ assert response.status_code == status.HTTP_200_OK
+ by_type = {f["type"]: f for f in response.json()}
+ assert by_type["bpf"]["parameters"] == [{"name": "Filters", "type": "text"}]
+ assert by_type["delay"]["parameters"][0] == {
+ "name": "Latency",
+ "minimum": 1,
+ "maximum": 32767,
+ "unit": "ms",
+ "type": "int",
+ }
+
+ async def test_link_markers_keep_missing_keys_absent(
+ self, app: FastAPI, client: AsyncClient, project: Project
+ ) -> None:
+
+ link = UDPLink(project)
+ link._markers["legacy"] = {"bpf": "icmp", "tag": 1, "enabled": True}
+ link._markers["global-arp"] = {"bpf": "arp", "inherited_from": "arp", "capture_node_id": "n1"}
+ project._links = {link.id: link}
+
+ response = await client.get(app.url_path_for("get_markers", project_id=project.id, link_id=link.id))
+ assert response.status_code == status.HTTP_200_OK
+ assert response.json() == link._markers
+
+
+class TestMarkerAggregation:
+ async def test_project_markers_and_definitions(self, app: FastAPI, client: AsyncClient, project: Project) -> None:
+
+ link = UDPLink(project)
+ link._markers["web"] = {
+ "bpf": "tcp port 80",
+ "tag": 4,
+ "enabled": False,
+ "color": "#ff5722",
+ "highlight_duration": 800,
+ "capture_node_id": "n1",
+ "direction": "tx",
+ "data_link_type": "DLT_EN10MB",
+ }
+ link._markers["global-arp"] = {
+ "bpf": "arp",
+ "tag": None,
+ "enabled": True,
+ "color": None,
+ "highlight_duration": None,
+ "capture_node_id": "n1",
+ "direction": None,
+ "data_link_type": "DLT_EN10MB",
+ "inherited_from": "arp",
+ }
+ project._links = {link.id: link}
+ project._marker_definitions = {
+ "arp": {
+ "bpf": "arp",
+ "tag": None,
+ "direction": None,
+ "color": None,
+ "highlight_duration": None,
+ "data_link_type": "DLT_EN10MB",
+ "paused": False,
+ },
+ "old": {"bpf": "icmp", "tag": 5},
+ }
+
+ response = await client.get(app.url_path_for("get_project_markers", project_id=project.id))
+ assert response.status_code == status.HTTP_200_OK
+ assert response.json() == {
+ f"{link.id}/web": {**link._markers["web"], "link_id": link.id, "node_id": "n1"},
+ f"{link.id}/global-arp": {**link._markers["global-arp"], "link_id": link.id, "node_id": "n1"},
+ }
+
+ response = await client.get(app.url_path_for("get_marker_definitions", project_id=project.id))
+ assert response.status_code == status.HTTP_200_OK
+ assert response.json() == {
+ "arp": {**project._marker_definitions["arp"], "link_ids": [link.id]},
+ "old": {"bpf": "icmp", "tag": 5, "link_ids": []},
+ }
+
+
+class TestReplayFrameDetail:
+ async def test_frame_detail_passes_unknown_tree_keys_through(
+ self, app: FastAPI, client: AsyncClient, project: Project, monkeypatch
+ ) -> None:
+
+ detail = {
+ "ts": "1693472000.000000",
+ "source": {"node_id": "n1", "link_id": "l1", "marker": "icmp", "frame_number": 1},
+ "field_count": 3,
+ "hex": "0200",
+ "tree": [
+ {
+ "element": "proto",
+ "label": "Internet Protocol",
+ "name": "ip",
+ "filter_expr": "ip",
+ "pos": 14,
+ "size": 20,
+ "children": [{"label": "TTL: 64", "name": "ip.ttl", "expert": "Chat", "generated": True}],
+ "future_key": {"a": 1},
+ }
+ ],
+ }
+ monkeypatch.setattr(marker_replay, "decode_frame", AsyncMock(return_value=detail))
+
+ response = await client.get(
+ app.url_path_for("replay_tag_frame_detail", project_id=project.id, tag=7),
+ params={"ts": "1693472000.000000", "node_id": "n1", "link_id": "l1", "marker": "icmp"},
+ )
+ assert response.status_code == status.HTTP_200_OK
+ assert response.json() == detail
+
+ async def test_range_empty_timeline(self, app: FastAPI, client: AsyncClient, project: Project) -> None:
+
+ link = UDPLink(project)
+ link._markers["icmp"] = {
+ "bpf": "icmp",
+ "tag": 9,
+ "enabled": False,
+ "color": None,
+ "highlight_duration": None,
+ "capture_node_id": "n1",
+ "direction": None,
+ "data_link_type": "DLT_EN10MB",
+ }
+ project._links = {link.id: link}
+ _write_pcap(f"{project.markers_directory}/n1_{link.id}_icmp.pcap", [])
+
+ response = await client.get(app.url_path_for("replay_tag_range", project_id=project.id, tag=9))
+ assert response.status_code == status.HTTP_200_OK
+ assert response.json() == {
+ "tag": 9,
+ "start": None,
+ "end": None,
+ "frame_count": 0,
+ "sources": [
+ {"node_id": "n1", "link_id": link.id, "marker": "icmp", "data_link_type": "DLT_EN10MB", "count": 0}
+ ],
+ "frames": [],
+ }
diff --git a/tests/controller/test_project.py b/tests/controller/test_project.py
index 92d51cd82..b41c7cb53 100644
--- a/tests/controller/test_project.py
+++ b/tests/controller/test_project.py
@@ -1118,6 +1118,46 @@ async def test_node_name(project):
assert node.name == "R3"
+@pytest.mark.asyncio
+async def test_node_name_strict(project):
+
+ compute = MagicMock()
+ compute.id = "local"
+ response = MagicMock()
+ response.json = {"console": 2048}
+ compute.post = AsyncioMagicMock(return_value=response)
+
+ node = await project.add_node(compute, "R1", None, node_type="vpcs", strict_names=True)
+ assert node.name == "R1"
+ with pytest.raises(ControllerError):
+ await project.add_node(compute, "R1", None, node_type="vpcs", strict_names=True)
+ assert len(project.nodes) == 1
+
+ node = await project.add_node(compute, "R1", None, node_type="vpcs", strict_names=False)
+ assert node.name == "R2"
+ node = await project.add_node(compute, "T{0}", None, node_type="vpcs", strict_names=True)
+ assert node.name == "T1"
+ node = await project.add_node(compute, "T{0}", None, node_type="vpcs", strict_names=True)
+ assert node.name == "T2"
+
+
+@pytest.mark.asyncio
+async def test_node_name_released_when_creation_fails(project):
+
+ compute = MagicMock()
+ compute.id = "local"
+ compute.post = AsyncioMagicMock(side_effect=ControllerError("failure"))
+
+ with pytest.raises(ControllerError):
+ await project.add_node(compute, "R1", None, node_type="vpcs")
+
+ response = MagicMock()
+ response.json = {"console": 2048}
+ compute.post = AsyncioMagicMock(return_value=response)
+ node = await project.add_node(compute, "R1", None, node_type="vpcs", strict_names=True)
+ assert node.name == "R1"
+
+
@pytest.mark.asyncio
async def test_duplicate_node(project):