Merge branch '3.1' into fork/markparonyan/api-issue-10-0eztih

# Conflicts:
#	gns3server/schemas/__init__.py
This commit is contained in:
grossmj committed 2026-10-10 06:39:15 +02:00
commit 6004e06499
110 files changed
+21567 -701

No files matched your search

+37 -1
View File
@@ -25,19 +25,51 @@ from fastapi import APIRouter, Request
from gns3server import schemas
from gns3server.compute import MODULES
from gns3server.compute.ubridge.tc_probe import (
probe_bridge_tap_support,
probe_iol_tap_support,
probe_tap_support,
probe_tc_capabilities,
)
from gns3server.utils.path import get_default_project_directory
from gns3server.utils.tc_capabilities import usable_ebpf_modes
from gns3server.version import __version__
router = APIRouter()
@router.get("/capabilities", response_model=schemas.Capabilities)
def get_capabilities(request: Request) -> dict:
async def get_capabilities(request: Request) -> dict:
node_types = []
for module in MODULES:
node_types.extend(module.node_types())
# Kernel-datapath packet filters: what this host's uBridge can run,
# probed as the server's own user (None = unknown, old/missing uBridge).
ubridge_tc = None
caps = await probe_tc_capabilities()
if caps:
ubridge_tc = {
"netem": [keyword for keyword in caps.get("netem", "").split(",") if keyword],
"ebpf": caps.get("ebpf") == "1",
"ebpf_modes": list(usable_ebpf_modes(caps)),
"cbpf": caps.get("cbpf") == "1",
}
# Persistent TAP support: the anchor QEMU adapters need for the kernel
# datapath. None = unknown (probe failed or uBridge too old).
ubridge_tap = await probe_tap_support()
# IOL-port TAP termination (iol_bridge add_nio_tap): the anchor IOU's
# Ethernet ports need for the kernel datapath. None = unknown.
ubridge_iol_tap = await probe_iol_tap_support()
# Swappable TAP leg on the generic bridge module (bridge delete_nio_tap):
# what lets an IOL runner container's per-port bridge carry a kernel-link
# anchor. None = unknown (probe failed or uBridge too old).
ubridge_bridge_tap = await probe_bridge_tap_support()
# record the controller hostname or IP address
if request.client:
request.app.state.controller_host = request.client.host
@@ -49,4 +81,8 @@ def get_capabilities(request: Request) -> dict:
"memory": psutil.virtual_memory().total,
"disk_size": psutil.disk_usage(get_default_project_directory()).total,
"node_types": node_types,
"ubridge_tc": ubridge_tc,
"ubridge_tap": ubridge_tap,
"ubridge_iol_tap": ubridge_iol_tap,
"ubridge_bridge_tap": ubridge_bridge_tap,
}
+12 -6
View File
@@ -260,12 +260,15 @@ async def duplicate_docker_node(
@router.post(
"/{node_id}/adapters/{adapter_number}/ports/{port_number}/nio",
status_code=status.HTTP_201_CREATED,
response_model=schemas.UDPNIO,
response_model=Union[schemas.UDPNIO, schemas.BridgeNIO],
dependencies=[Depends(compute_authentication)],
)
async def create_docker_node_nio(
adapter_number: int, port_number: int, nio_data: schemas.UDPNIO, node: DockerVM = Depends(dep_node)
) -> schemas.UDPNIO:
adapter_number: int,
port_number: int,
nio_data: Union[schemas.UDPNIO, schemas.BridgeNIO],
node: DockerVM = Depends(dep_node),
) -> Union[schemas.UDPNIO, schemas.BridgeNIO]:
"""
Add a NIO (Network Input/Output) to the node.
The port number on the Docker node is always 0.
@@ -279,12 +282,15 @@ async def create_docker_node_nio(
@router.put(
"/{node_id}/adapters/{adapter_number}/ports/{port_number}/nio",
status_code=status.HTTP_201_CREATED,
response_model=schemas.UDPNIO,
response_model=Union[schemas.UDPNIO, schemas.BridgeNIO],
dependencies=[Depends(compute_authentication)],
)
async def update_docker_node_nio(
adapter_number: int, port_number: int, nio_data: schemas.UDPNIO, node: DockerVM = Depends(dep_node)
) -> schemas.UDPNIO:
adapter_number: int,
port_number: int,
nio_data: Union[schemas.UDPNIO, schemas.BridgeNIO],
node: DockerVM = Depends(dep_node),
) -> Union[schemas.UDPNIO, schemas.BridgeNIO]:
"""
Update a NIO (Network Input/Output) on the node.
The port number on the Docker node is always 0.
@@ -29,6 +29,7 @@ from fastapi.responses import StreamingResponse
from gns3server import schemas
from gns3server.compute.dynamips import Dynamips
from gns3server.compute.dynamips.nodes.router import Router
from gns3server.compute.nios.nio_bridge import NIOBridge
from .dependencies.authentication import compute_authentication, ws_compute_authentication
@@ -173,12 +174,15 @@ async def reload_router(node: Router = Depends(dep_node)) -> None:
@router.post(
"/{node_id}/adapters/{adapter_number}/ports/{port_number}/nio",
status_code=status.HTTP_201_CREATED,
response_model=schemas.UDPNIO,
response_model=Union[schemas.UDPNIO, schemas.BridgeNIO],
dependencies=[Depends(compute_authentication)],
)
async def create_nio(
adapter_number: int, port_number: int, nio_data: schemas.UDPNIO, node: Router = Depends(dep_node)
) -> schemas.UDPNIO:
adapter_number: int,
port_number: int,
nio_data: Union[schemas.UDPNIO, schemas.BridgeNIO],
node: Router = Depends(dep_node),
) -> Union[schemas.UDPNIO, schemas.BridgeNIO]:
"""
Add a NIO (Network Input/Output) to the node.
"""
@@ -191,12 +195,15 @@ async def create_nio(
@router.put(
"/{node_id}/adapters/{adapter_number}/ports/{port_number}/nio",
status_code=status.HTTP_201_CREATED,
response_model=schemas.UDPNIO,
response_model=Union[schemas.UDPNIO, schemas.BridgeNIO],
dependencies=[Depends(compute_authentication)],
)
async def update_nio(
adapter_number: int, port_number: int, nio_data: schemas.UDPNIO, node: Router = Depends(dep_node)
) -> schemas.UDPNIO:
adapter_number: int,
port_number: int,
nio_data: Union[schemas.UDPNIO, schemas.BridgeNIO],
node: Router = Depends(dep_node),
) -> Union[schemas.UDPNIO, schemas.BridgeNIO]:
"""
Update a NIO (Network Input/Output) on the node.
"""
@@ -206,6 +213,10 @@ async def update_nio(
if nio_data.filters:
nio.filters = nio_data.filters
nio.markers = nio_data.markers or {}
# Suspend is what the compute turns into an admin-down anchor on a
# kernel link (native carrier), so it must reach the NIO like it does
# on the Docker/QEMU/IOU routes.
nio.suspend = getattr(nio_data, "suspend", None) or False
await node.slot_update_nio_binding(adapter_number, port_number, nio)
return nio.asdict()
@@ -221,7 +232,10 @@ async def delete_nio(adapter_number: int, port_number: int, node: Router = Depen
"""
nio = await node.slot_remove_nio_binding(adapter_number, port_number)
await nio.delete()
if not isinstance(nio, NIOBridge):
# A kernel NIO has no hypervisor side left: its TAP NIO was deleted
# by the slot binding removal itself.
await nio.delete()
@router.post(
@@ -23,7 +23,7 @@ through uBridge's ``brctl`` module (see
"""
import os
from typing import Any
from typing import Any, Union
from uuid import UUID
from fastapi import APIRouter, Body, Depends, HTTPException, Path, status
@@ -109,8 +109,21 @@ async def update_ethernet_switch(
if "usage" in data:
node.usage = data["usage"]
if "ports_mapping" in data:
# capture the mapping before the setter replaces it: the VLAN
# reconcile diffs old against new to touch only what changed, in
# place (the setter itself keeps the port-count guard)
previous_mapping = [dict(port) for port in node.ports_mapping]
node.ports_mapping = data["ports_mapping"]
await node.update_port_settings()
try:
await node.update_port_settings(previous_mapping)
except Exception:
# Nothing, or only part, of the new mapping was applied — keep
# the old one so the failed change stays visible in GET and a
# retry still diffs against it. A kept-new mapping would turn
# the retry into a no-op that reports success with the VLANs
# never applied.
node.ports_mapping = previous_mapping
raise
if "console_type" in data:
node.console_type = data["console_type"]
node.updated()
@@ -174,15 +187,20 @@ def reload_ethernet_switch(node: EthernetSwitch = Depends(dep_node)) -> None:
@router.post(
"/{node_id}/adapters/{adapter_number}/ports/{port_number}/nio",
status_code=status.HTTP_201_CREATED,
response_model=schemas.UDPNIO,
response_model=Union[schemas.UDPNIO, schemas.AnchorNIO],
)
async def create_ethernet_switch_nio(
*,
adapter_number: int = Path(..., ge=0, le=0),
port_number: int,
nio_data: schemas.UDPNIO,
nio_data: Union[schemas.UDPNIO, schemas.AnchorNIO],
node: EthernetSwitch = Depends(dep_node),
) -> schemas.UDPNIO:
) -> Union[schemas.UDPNIO, schemas.AnchorNIO]:
"""
Add a NIO (Network Input/Output) to the node: a UDP NIO wires the relay
datapath, an anchor NIO absorbs the peer's kernel anchor into this
switch's kernel bridge. The adapter number on the switch is always 0.
"""
nio = Builtin.instance().create_nio(jsonable_encoder(nio_data, exclude_unset=True))
await node.add_nio(nio, port_number)
@@ -192,19 +210,21 @@ async def create_ethernet_switch_nio(
@router.put(
"/{node_id}/adapters/{adapter_number}/ports/{port_number}/nio",
status_code=status.HTTP_201_CREATED,
response_model=schemas.UDPNIO,
response_model=Union[schemas.UDPNIO, schemas.AnchorNIO],
)
async def update_ethernet_switch_nio(
*,
adapter_number: int = Path(..., ge=0, le=0),
port_number: int,
nio_data: schemas.UDPNIO,
nio_data: Union[schemas.UDPNIO, schemas.AnchorNIO],
node: EthernetSwitch = Depends(dep_node),
) -> schemas.UDPNIO:
) -> Union[schemas.UDPNIO, schemas.AnchorNIO]:
"""
Update a NIO (Network Input/Output) on the node: re-apply the packet
filters and traffic-insight markers carried by the NIO onto the port's
uBridge relay. The adapter number on the switch is always 0.
filters, traffic-insight markers and suspend state carried by the NIO —
on the port's uBridge relay for a relay port, on the absorbed anchor
(tc + kernel markers + carrier) for a kernel port. The adapter number
on the switch is always 0.
"""
nio = node.get_nio(port_number)
@@ -212,6 +232,10 @@ async def update_ethernet_switch_nio(
if nio_data.filters:
nio.filters = nio_data.filters
nio.markers = nio_data.markers or {}
# Suspend is what the compute turns into an admin-down anchor on a
# kernel link (native carrier), so it must reach the NIO like it does
# on the other node routes.
nio.suspend = getattr(nio_data, "suspend", None) or False
await node.update_nio(port_number, nio)
return nio.asdict()
+14 -8
View File
@@ -187,15 +187,15 @@ async def reload_iou_node(node: IOUVM = Depends(dep_node)) -> None:
@router.post(
"/{node_id}/adapters/{adapter_number}/ports/{port_number}/nio",
status_code=status.HTTP_201_CREATED,
response_model=Union[schemas.EthernetNIO, schemas.TAPNIO, schemas.UDPNIO],
response_model=Union[schemas.EthernetNIO, schemas.TAPNIO, schemas.UDPNIO, schemas.BridgeNIO],
dependencies=[Depends(compute_authentication)],
)
async def create_iou_node_nio(
adapter_number: int,
port_number: int,
nio_data: Union[schemas.EthernetNIO, schemas.TAPNIO, schemas.UDPNIO],
nio_data: Union[schemas.EthernetNIO, schemas.TAPNIO, schemas.UDPNIO, schemas.BridgeNIO],
node: IOUVM = Depends(dep_node),
) -> Union[schemas.EthernetNIO, schemas.TAPNIO, schemas.UDPNIO]:
) -> Union[schemas.EthernetNIO, schemas.TAPNIO, schemas.UDPNIO, schemas.BridgeNIO]:
"""
Add a NIO (Network Input/Output) to the node.
"""
@@ -208,25 +208,31 @@ async def create_iou_node_nio(
@router.put(
"/{node_id}/adapters/{adapter_number}/ports/{port_number}/nio",
status_code=status.HTTP_201_CREATED,
response_model=Union[schemas.EthernetNIO, schemas.TAPNIO, schemas.UDPNIO],
response_model=Union[schemas.EthernetNIO, schemas.TAPNIO, schemas.UDPNIO, schemas.BridgeNIO],
dependencies=[Depends(compute_authentication)],
)
async def update_iou_node_nio(
adapter_number: int,
port_number: int,
nio_data: Union[schemas.EthernetNIO, schemas.TAPNIO, schemas.UDPNIO],
nio_data: Union[schemas.EthernetNIO, schemas.TAPNIO, schemas.UDPNIO, schemas.BridgeNIO],
node: IOUVM = Depends(dep_node),
) -> Union[schemas.EthernetNIO, schemas.TAPNIO, schemas.UDPNIO]:
) -> Union[schemas.EthernetNIO, schemas.TAPNIO, schemas.UDPNIO, schemas.BridgeNIO]:
"""
Update a NIO (Network Input/Output) on the node.
"""
nio = node.get_nio(adapter_number, port_number)
nio.filters.clear()
if isinstance(nio_data, schemas.UDPNIO) and nio_data.filters:
# EthernetNIO/TAPNIO carry no filters field; UDPNIO (relay) and
# BridgeNIO (kernel datapath) both do.
if isinstance(nio_data, (schemas.UDPNIO, schemas.BridgeNIO)) and nio_data.filters:
nio.filters = nio_data.filters
# NIO type is a Union (Ethernet/TAP/UDP); only UDPNIO carries markers.
# NIO type is a Union (Ethernet/TAP/UDP/bridge); only UDPNIO and
# BridgeNIO carry markers and suspend. Suspend is what the compute turns
# into an admin-down anchor on a kernel link (native carrier), so it must
# reach the NIO like it does on the Docker/QEMU routes.
nio.markers = getattr(nio_data, "markers", None) or {}
nio.suspend = getattr(nio_data, "suspend", None) or False
await node.adapter_update_nio_binding(adapter_number, port_number, nio)
return nio.asdict()
+8 -7
View File
@@ -269,18 +269,19 @@ async def resume_qemu_node(node: QemuVM = Depends(dep_node)) -> None:
@router.post(
"/{node_id}/adapters/{adapter_number}/ports/{port_number}/nio",
status_code=status.HTTP_201_CREATED,
response_model=schemas.UDPNIO,
response_model=Union[schemas.UDPNIO, schemas.BridgeNIO],
dependencies=[Depends(compute_authentication)],
)
async def create_qemu_node_nio(
*,
adapter_number: int,
port_number: int = Path(..., ge=0, le=0),
nio_data: schemas.UDPNIO,
nio_data: Union[schemas.UDPNIO, schemas.BridgeNIO],
node: QemuVM = Depends(dep_node),
) -> schemas.UDPNIO:
) -> Union[schemas.UDPNIO, schemas.BridgeNIO]:
"""
Add a NIO (Network Input/Output) to the node.
Add a NIO (Network Input/Output) to the node: a UDP relay NIO, or a
kernel-datapath bridge NIO the node enslaves its adapter TAP into.
The port number on the Qemu node is always 0.
"""
@@ -292,16 +293,16 @@ async def create_qemu_node_nio(
@router.put(
"/{node_id}/adapters/{adapter_number}/ports/{port_number}/nio",
status_code=status.HTTP_201_CREATED,
response_model=schemas.UDPNIO,
response_model=Union[schemas.UDPNIO, schemas.BridgeNIO],
dependencies=[Depends(compute_authentication)],
)
async def update_qemu_node_nio(
*,
adapter_number: int,
port_number: int = Path(..., ge=0, le=0),
nio_data: schemas.UDPNIO,
nio_data: Union[schemas.UDPNIO, schemas.BridgeNIO],
node: QemuVM = Depends(dep_node),
) -> schemas.UDPNIO:
) -> Union[schemas.UDPNIO, schemas.BridgeNIO]:
"""
Update a NIO (Network Input/Output) on the node.
The port number on the Qemu node is always 0.
+54 -10
View File
@@ -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,6 +169,40 @@ 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.LinkFilterInfo],
+45 -1
View File
@@ -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],