Merge remote-tracking branch 'origin/3.1' into 3.1

This commit is contained in:
grossmj committed 2026-07-21 17:04:35 +02:00
commit 0091f2c64a
63 files changed
+3685 -382

No files matched your search

@@ -184,6 +184,8 @@ async def update_cloud_nio(
nio.filters.clear()
if nio_data.filters:
nio.filters = nio_data.filters
# NIO type is a Union (Ethernet/TAP/UDP); only UDPNIO carries markers.
nio.markers = getattr(nio_data, "markers", None) or {}
await node.update_nio(port_number, nio)
return nio.asdict()
@@ -29,7 +29,6 @@ from typing import Union
from gns3server import schemas
from gns3server.compute.docker import Docker
from gns3server.compute.docker.docker_vm import DockerVM
from .dependencies.authentication import compute_authentication, ws_compute_authentication
responses = {404: {"model": schemas.ErrorMessage, "description": "Could not find project or Docker node"}}
@@ -293,6 +292,7 @@ async def update_docker_node_nio(
nio.filters.clear()
if nio_data.filters:
nio.filters = nio_data.filters
nio.markers = nio_data.markers or {}
await node.adapter_update_nio_binding(adapter_number, nio)
return nio.asdict()
@@ -235,6 +235,7 @@ async def update_nio(
nio.filters.clear()
if nio_data.filters:
nio.filters = nio_data.filters
nio.markers = nio_data.markers or {}
await node.slot_update_nio_binding(adapter_number, port_number, nio)
return nio.asdict()
@@ -16,6 +16,10 @@
"""
API routes for Ethernet switch nodes.
The Ethernet switch is a builtin node backed by a Linux kernel bridge driven
through uBridge's ``brctl`` module (see
``gns3server.compute.builtin.nodes.ethernet_switch``).
"""
import os
@@ -25,8 +29,8 @@ from fastapi.encoders import jsonable_encoder
from fastapi.responses import StreamingResponse
from uuid import UUID
from gns3server.compute.dynamips import Dynamips
from gns3server.compute.dynamips.nodes.ethernet_switch import EthernetSwitch
from gns3server.compute.builtin import Builtin
from gns3server.compute.builtin.nodes.ethernet_switch import EthernetSwitch
from gns3server import schemas
responses = {404: {"model": schemas.ErrorMessage, "description": "Could not find project or Ethernet switch node"}}
@@ -39,8 +43,8 @@ def dep_node(project_id: UUID, node_id: UUID) -> EthernetSwitch:
Dependency to retrieve a node.
"""
dynamips_manager = Dynamips.instance()
node = dynamips_manager.get_node(str(node_id), project_id=str(project_id))
builtin_manager = Builtin.instance()
node = builtin_manager.get_node(str(node_id), project_id=str(project_id))
return node
@@ -55,10 +59,9 @@ async def create_ethernet_switch(project_id: UUID, node_data: schemas.EthernetSw
Create a new Ethernet switch.
"""
# Use the Dynamips Ethernet switch to simulate this node
dynamips_manager = Dynamips.instance()
builtin_manager = Builtin.instance()
node_data = jsonable_encoder(node_data, exclude_unset=True)
node = await dynamips_manager.create_node(
node = await builtin_manager.create_node(
node_data.pop("name"),
str(project_id),
node_data.get("node_id"),
@@ -67,7 +70,7 @@ async def create_ethernet_switch(project_id: UUID, node_data: schemas.EthernetSw
node_type="ethernet_switch",
ports=node_data.get("ports_mapping"),
)
node.usage = node_data.get("usage", "")
return node.asdict()
@@ -86,7 +89,7 @@ async def duplicate_ethernet_switch(
Duplicate an Ethernet switch.
"""
new_node = await Dynamips.instance().duplicate_node(node.id, str(destination_node_id))
new_node = await Builtin.instance().duplicate_node(node.id, str(destination_node_id))
return new_node.asdict()
@@ -101,7 +104,9 @@ async def update_ethernet_switch(
node_data = jsonable_encoder(node_data, exclude_unset=True)
if "name" in node_data and node.name != node_data["name"]:
await node.set_name(node_data["name"])
node.name = node_data["name"]
if "usage" in node_data:
node.usage = node_data["usage"]
if "ports_mapping" in node_data:
node.ports_mapping = node_data["ports_mapping"]
await node.update_port_settings()
@@ -117,7 +122,7 @@ async def delete_ethernet_switch(node: EthernetSwitch = Depends(dep_node)) -> No
Delete an Ethernet switch.
"""
await Dynamips.instance().delete_node(node.id)
await Builtin.instance().delete_node(node.id)
@router.post("/{node_id}/start", status_code=status.HTTP_204_NO_CONTENT)
@@ -182,7 +187,7 @@ async def create_ethernet_switch_nio(
node: EthernetSwitch = Depends(dep_node)
) -> schemas.UDPNIO:
nio = await Dynamips.instance().create_nio(node, jsonable_encoder(nio_data, exclude_unset=True))
nio = Builtin.instance().create_nio(jsonable_encoder(nio_data, exclude_unset=True))
await node.add_nio(nio, port_number)
return nio.asdict()
@@ -199,8 +204,7 @@ async def delete_ethernet_switch_nio(
The adapter number on the switch is always 0.
"""
nio = await node.remove_nio(port_number)
await nio.delete()
await node.remove_nio(port_number)
@router.post("/{node_id}/adapters/{adapter_number}/ports/{port_number}/capture/start")
@@ -251,5 +255,5 @@ async def stream_pcap_file(
"""
nio = node.get_nio(port_number)
stream = Dynamips.instance().stream_pcap_file(nio, node.project.id)
stream = Builtin.instance().stream_pcap_file(nio, node.project.id)
return StreamingResponse(stream, media_type="application/vnd.tcpdump.pcap")
@@ -254,6 +254,8 @@ async def update_iou_node_nio(
nio.filters.clear()
if nio_data.filters:
nio.filters = nio_data.filters
# NIO type is a Union (Ethernet/TAP/UDP); only UDPNIO carries markers.
nio.markers = getattr(nio_data, "markers", None) or {}
await node.adapter_update_nio_binding(adapter_number, port_number, nio)
return nio.asdict()
+1 -1
View File
@@ -30,7 +30,6 @@ from gns3server import schemas
from gns3server.compute import qemu
from gns3server.compute.qemu import Qemu
from gns3server.compute.qemu.qemu_vm import QemuVM
from .dependencies.authentication import compute_authentication, ws_compute_authentication
import logging
@@ -321,6 +320,7 @@ async def update_qemu_node_nio(
if nio_data.filters:
nio.filters = nio_data.filters
nio.suspend = nio_data.suspend
nio.markers = nio_data.markers or {}
await node.adapter_update_nio_binding(adapter_number, nio)
return nio.asdict()
+2 -1
View File
@@ -29,7 +29,6 @@ from uuid import UUID
from gns3server import schemas
from gns3server.compute.vpcs import VPCS
from gns3server.compute.vpcs.vpcs_vm import VPCSVM
from .dependencies.authentication import compute_authentication, ws_compute_authentication
responses = {404: {"model": schemas.ErrorMessage, "description": "Could not find project or VMware node"}}
@@ -240,6 +239,7 @@ async def update_vpcs_node_nio(
nio.filters.clear()
if nio_data.filters:
nio.filters = nio_data.filters
nio.markers = nio_data.markers or {}
await node.port_update_nio_binding(port_number, nio)
return nio.asdict()
@@ -303,6 +303,7 @@ async def stop_vpcs_node_capture(
await node.stop_capture(port_number)
@router.get(
"/{node_id}/adapters/{adapter_number}/ports/{port_number}/capture/stream",
dependencies=[Depends(compute_authentication)]
@@ -52,6 +52,10 @@ def has_privilege_on_websocket(
current_user: schemas.User = Depends(get_current_active_user_from_websocket),
rbac_repo: RbacRepository = Depends(get_repository(RbacRepository))
):
# Authentication may have failed and closed the socket inside the auth
# dependency, returning None — bail out before touching the user object.
if current_user is None:
return None
if not current_user.is_superadmin:
path = re.sub(r"^/v[0-9]", "", websocket.url.path) # remove the prefix (e.g. "/v3") from URL path
log.debug(f"Checking user {current_user.username} has privilege {privilege_name} on '{path}'")
+92 -1
View File
@@ -27,7 +27,7 @@ from fastapi import APIRouter, Depends, Request, status, WebSocket
from fastapi.responses import FileResponse, StreamingResponse
from fastapi.encoders import jsonable_encoder
from typing import List, Union
from uuid import UUID
from uuid import UUID, uuid4
from gns3server.controller import Controller
from gns3server.controller.controller_error import ControllerError
@@ -424,6 +424,97 @@ async def web_wireshark_websocket(
pass
@router.get(
"/{link_id}/markers",
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.
Required privilege: Link.Audit
"""
return link.markers
@router.post(
"/{link_id}/markers",
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:
"""
Attach a traffic-insight marker to the link.
On BPF match uBridge emits MARK signals and appends packets to a pcap.
Required privilege: Link.Modify
"""
# Auto-generate a link-unique name when the caller omits one. The short
# uuid suffix avoids the collision that `marker-{link.id[:8]}` alone would
# cause on the second anonymous marker on the same link (start_marker
# rejects duplicate names).
if marker_data.name and marker_data.name.lower().startswith("global"):
raise ControllerError('Names starting with "global" are reserved for inherited markers')
name = marker_data.name or f"marker-{link.id[:8]}-{uuid4().hex[:4]}"
await link.start_marker(
name=name,
bpf=marker_data.bpf,
tag=marker_data.tag,
color=marker_data.color,
highlight_duration=marker_data.highlight_duration,
)
return link.markers.get(name, {})
@router.delete(
"/{link_id}/markers/{marker_name}",
status_code=status.HTTP_204_NO_CONTENT,
dependencies=[Depends(has_privilege("Link.Modify"))]
)
async def delete_marker(
marker_name: str,
link: Link = Depends(dep_link)
) -> None:
"""
Remove a traffic-insight marker from the link.
Required privilege: Link.Modify
"""
await link.stop_marker(marker_name)
@router.put(
"/{link_id}/markers/{marker_name}",
dependencies=[Depends(has_privilege("Link.Modify"))]
)
async def update_marker(
marker_name: str,
marker_data: schemas.MarkerCreate,
link: Link = Depends(dep_link)
) -> dict:
"""
Update a traffic-insight marker (change BPF, tag, or enabled).
Required privilege: Link.Modify
"""
await link.update_marker(
name=marker_name,
bpf=marker_data.bpf if marker_data.bpf else None,
tag=marker_data.tag,
color=marker_data.color,
enabled=marker_data.enabled,
highlight_duration=marker_data.highlight_duration,
)
return link.markers.get(marker_name, {})
@router.get(
"/{link_id}/iface",
response_model=Union[schemas.UDPPortInfo, schemas.EthernetPortInfo],
+12 -1
View File
@@ -243,14 +243,25 @@ async def reload_all_nodes(project: Project = Depends(dep_project)) -> None:
raise
# Node types that need live host interface data from compute
_HOST_INTERFACE_NODE_TYPES = {"cloud", "nat"}
@router.get("/{node_id}", response_model=schemas.Node, dependencies=[Depends(has_privilege("Node.Audit"))])
def get_node(node: Node = Depends(dep_node)) -> schemas.Node:
async def get_node(node: Node = Depends(dep_node)) -> schemas.Node:
"""
Return a node from a given project.
Required privilege: Node.Audit
"""
if node.node_type in _HOST_INTERFACE_NODE_TYPES:
try:
response = await node.get()
await node.parse_node_response(response.json)
except Exception:
# If compute is unreachable, still return cached data
log.warning(f"Could not refresh node {node.id} from compute, returning cached data")
return node.asdict()
@@ -203,6 +203,119 @@ def get_project_stats(project: Project = Depends(dep_project)) -> dict:
return project.stats()
@router.get("/{project_id}/markers", 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.
Each entry is keyed ``"{link_id}/{marker_name}"`` and carries the
marker's BPF, tag, color, enabled flag, plus its parent ``link_id``
and capture-side ``node_id`` for frontend filtering / grouping.
Required privilege: Project.Audit
"""
return project.markers
# ---------------------------------------------------------------------------
# Project-level marker definitions (global rules inherited by every link)
# ---------------------------------------------------------------------------
@router.get(
"/{project_id}/marker-definitions",
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.
Required privilege: Project.Audit
"""
result = {}
for name, d in project.marker_definitions.items():
# Collect which links currently carry an inherited copy.
bound = [
lid for lid, link in project.links.items()
if f"global-{name}" in link.markers
and link.markers[f"global-{name}"].get("inherited_from") == name
]
result[name] = {**d, "link_ids": bound}
return result
@router.post(
"/{project_id}/marker-definitions",
status_code=status.HTTP_201_CREATED,
dependencies=[Depends(has_privilege("Project.Modify"))]
)
async def create_marker_definition(
def_data: schemas.MarkerDefinitionCreate,
project: Project = Depends(dep_project)
) -> dict:
"""
Create a project-level marker definition and fan out to every link.
Required privilege: Project.Modify
"""
if def_data.name and def_data.name.lower().startswith("global"):
raise ControllerError('Names starting with "global" are reserved for inherited markers')
name = def_data.name or f"def-{project.id[:8]}"
await project.create_marker_definition(
name=name,
bpf=def_data.bpf,
tag=def_data.tag,
color=def_data.color,
highlight_duration=def_data.highlight_duration,
)
return project.marker_definitions.get(name, {})
@router.put(
"/{project_id}/marker-definitions/{def_name}",
dependencies=[Depends(has_privilege("Project.Modify"))]
)
async def update_marker_definition(
def_name: str,
def_data: schemas.MarkerDefinitionCreate,
project: Project = Depends(dep_project)
) -> dict:
"""
Update a marker definition and sync all inherited copies on every link.
Required privilege: Project.Modify
"""
await project.update_marker_definition(
name=def_name,
bpf=def_data.bpf if def_data.bpf else None,
tag=def_data.tag,
color=def_data.color,
highlight_duration=def_data.highlight_duration,
)
return project.marker_definitions.get(def_name, {})
@router.delete(
"/{project_id}/marker-definitions/{def_name}",
status_code=status.HTTP_204_NO_CONTENT,
dependencies=[Depends(has_privilege("Project.Modify"))]
)
async def delete_marker_definition(
def_name: str,
project: Project = Depends(dep_project)
) -> None:
"""
Delete a marker definition and remove all inherited copies from every link.
Required privilege: Project.Modify
"""
await project.delete_marker_definition(def_name)
@router.post(
"/{project_id}/close",
status_code=status.HTTP_204_NO_CONTENT,
+19 -32
View File
@@ -86,13 +86,13 @@ from .device_config import (
)
from .nodes import (
get_nodes_handler, get_node_handler, start_node_handler,
stop_node_handler, reload_node_handler, suspend_node_handler,
stop_node_handler, suspend_node_handler,
create_node_handler, delete_node_handler, update_node_handler,
get_node_console_info_handler,
list_node_files_handler, get_node_file_handler,
write_node_file_handler, delete_node_file_handler,
start_all_nodes_handler, stop_all_nodes_handler,
suspend_all_nodes_handler, reload_all_nodes_handler,
suspend_all_nodes_handler,
duplicate_node_handler, isolate_node_handler,
unisolate_node_handler, get_node_links_handler,
)
@@ -194,6 +194,12 @@ _jwt_token_var: contextvars.ContextVar[str | None] = contextvars.ContextVar(
_jwt_username_var: contextvars.ContextVar[str | None] = contextvars.ContextVar(
"mcp_jwt_username", default=None
)
# token_version extracted during token validation — short-lived JWTs minted for
# download/console URLs must carry the same version, or the revocation check
# (token_data.token_version != user.token_version) rejects them as "revoked".
_jwt_token_version_var: contextvars.ContextVar[int] = contextvars.ContextVar(
"mcp_jwt_token_version", default=0
)
# ── Token validation ──────────────────────────────────────────────────
@@ -208,8 +214,9 @@ async def _resolve_token(token: str) -> str | None:
"""
# Try JWT first
try:
username = auth_service.get_username_from_token(token)
_jwt_username_var.set(username)
token_data = auth_service.get_token_data(token)
_jwt_username_var.set(token_data.username)
_jwt_token_version_var.set(token_data.token_version)
return token
except Exception:
pass
@@ -233,7 +240,8 @@ async def _resolve_token(token: str) -> str | None:
user = await user_repo.get_user(db_key.user_id)
if user:
_jwt_username_var.set(user.username)
fresh_token = auth_service.create_access_token(user.username)
_jwt_token_version_var.set(user.token_version)
fresh_token = auth_service.create_access_token(user.username, token_version=user.token_version)
return fresh_token
except Exception:
pass
@@ -292,6 +300,7 @@ def _run_handler_sync(handler, params: dict[str, Any]) -> list[dict[str, Any]]:
"server_url": _server_url(),
"jwt_token": _jwt_token_var.get(),
"jwt_username": _jwt_username_var.get(),
"jwt_token_version": _jwt_token_version_var.get(),
}
result = handler(params, ctx)
return [{"type": "text", "text": json.dumps(result, ensure_ascii=False, default=str)}]
@@ -461,20 +470,6 @@ async def node_stop(
params["node_id"] = node_id
return await asyncio.to_thread(_run_handler_sync, stop_node_handler, params)
@mcp.tool()
async def node_reload(
project_id: Annotated[str, Field(description="UUID of the project")],
node_id: Annotated[str | None, Field(description="Node UUID (single mode)")] = None,
node_ids: Annotated[list[str] | None, Field(description="Batch mode: [\"uuid1\",\"uuid2\"] — reload multiple nodes in parallel")] = None,
) -> list[dict[str, Any]]:
"""Reload (restart) one or more nodes. Provide node_id for single, or node_ids for batch."""
params = {"project_id": project_id}
if node_ids:
params["node_ids"] = node_ids
else:
params["node_id"] = node_id
return await asyncio.to_thread(_run_handler_sync, reload_node_handler, params)
@mcp.tool()
async def node_suspend(
project_id: Annotated[str, Field(description="UUID of the project")],
@@ -562,16 +557,18 @@ async def node_console(
Complete workflow:
1. Call this tool with project_id and node_id to get the WebSocket URL
2. Connect to the returned URL using websocat in text mode (-t):
> websocat -t "ws://<your-gns3-server-host>:3080/v3/projects/{project_id}/nodes/{node_id}/console/ws?token={jwt_token}"
> websocat -t --no-close "ws://<your-gns3-server-host>:3080/v3/projects/{project_id}/nodes/{node_id}/console/ws?token={jwt_token}"
3. Send device commands with \\r\\n line endings via heredoc:
> websocat -t "ws://..." <<< $'\\r\\nenable\\r\\nshow version\\r\\nexit\\r\\n'
> websocat -t --no-close "ws://..." <<< $'\\r\\nenable\\r\\nshow version\\r\\nexit\\r\\n'
4. Receive response: websocat receives and displays device output
Use 'timeout' to avoid connection hanging:
> timeout 10 websocat -t "ws://..." <<< $'commands\\r\\n'
> timeout 10 websocat -t --no-close "ws://..." <<< $'commands\\r\\n'
Key points:
- Use \\r\\n (not \\n) to match console protocol line endings
- Use $'...' format for escape sequences in bash
- --no-close keeps the WebSocket open after stdin (heredoc) hits EOF, so
device output is not cut off before it arrives
- Set a timeout to prevent hanging connections
"""
return await asyncio.to_thread(_run_handler_sync, get_node_console_info_handler, {
@@ -873,16 +870,6 @@ async def node_suspend_all(
})
@mcp.tool()
async def node_reload_all(
project_id: Annotated[str, Field(description="UUID of the project")],
) -> list[dict[str, Any]]:
"""Reload (restart) all nodes in a project."""
return await asyncio.to_thread(_run_handler_sync, reload_all_nodes_handler, {
"project_id": project_id,
})
@mcp.tool()
async def node_duplicate(
project_id: Annotated[str, Field(description="UUID of the project")],
+1 -1
View File
@@ -336,7 +336,7 @@ def download_capture_file_handler(params: dict[str, Any], gns3_ctx: dict[str, An
if not project_id:
return {"error": "project_id is required"}
username = gns3_ctx.get("jwt_username")
download_token = auth_service.create_access_token(username, expires_in=10) if username else None
download_token = auth_service.create_access_token(username, token_version=gns3_ctx.get("jwt_token_version", 0), expires_in=10) if username else None
link_ids = params.get("link_ids")
if link_ids:
+2 -42
View File
@@ -159,24 +159,6 @@ def stop_node_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dict[
return {"message": f"Node {node_id} stopped", "node_id": node_id}
def reload_node_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dict[str, Any]:
project_id = params.get("project_id")
if not project_id:
return {"error": "project_id is required"}
node_ids = params.get("node_ids")
if node_ids:
if not isinstance(node_ids, list):
return {"error": "node_ids must be a list"}
conn = _get_connector(gns3_ctx)
return _batch_lifecycle(project_id, node_ids, "reload", conn, "reloaded")
node_id = params.get("node_id")
if not node_id:
return {"error": "node_id or node_ids is required"}
conn = _get_connector(gns3_ctx)
conn.http_call("post", f"{conn.base_url}/projects/{project_id}/nodes/{node_id}/reload")
return {"message": f"Node {node_id} reloaded", "node_id": node_id}
def suspend_node_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dict[str, Any]:
project_id = params.get("project_id")
if not project_id:
@@ -316,7 +298,7 @@ def get_node_console_info_handler(params: dict[str, Any], gns3_ctx: dict[str, An
console_type = node.get("console_type", "unknown")
# Short-lived JWT for the WebSocket URL (10 min)
username = gns3_ctx.get("jwt_username")
ws_token = auth_service.create_access_token(username, expires_in=10) if username else None
ws_token = auth_service.create_access_token(username, token_version=gns3_ctx.get("jwt_token_version", 0), expires_in=10) if username else None
raw_url = f"{gns3_ctx['server_url']}/v3/projects/{project_id}/nodes/{node_id}/console/ws"
if ws_token:
raw_url += f"?token={ws_token}"
@@ -328,7 +310,7 @@ def get_node_console_info_handler(params: dict[str, Any], gns3_ctx: dict[str, An
"node_name": node.get("name"),
"console_type": console_type,
"ws_url": ws_url,
"command": f"websocat {ws_url}",
"command": f"websocat -t --no-close {ws_url}",
}
if console_type in ("vnc",):
result["vnc_url"] = f"/v3/projects/{project_id}/nodes/{node_id}/console/vnc?token={gns3_ctx['jwt_token']}"
@@ -452,15 +434,6 @@ def suspend_all_nodes_handler(params: dict[str, Any], gns3_ctx: dict[str, Any])
return {"message": "All nodes suspended", "project_id": project_id}
def reload_all_nodes_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dict[str, Any]:
project_id = params.get("project_id")
if not project_id:
return {"error": "project_id is required"}
conn = _get_connector(gns3_ctx)
conn.http_call("post", f"{conn.base_url}/projects/{project_id}/nodes/reload")
return {"message": "All nodes reloaded", "project_id": project_id}
def duplicate_node_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dict[str, Any]:
project_id = params.get("project_id")
node_id = params.get("node_id")
@@ -556,19 +529,6 @@ NODE_TOOLS = [
},
"handler": stop_node_handler,
},
{
"name": "reload_node",
"description": "Reload (restart) a node in a project",
"parameters": {
"type": "object",
"properties": {
"project_id": {"type": "string", "description": "Project UUID"},
"node_id": {"type": "string", "description": "Node UUID"},
},
"required": ["project_id", "node_id"],
},
"handler": reload_node_handler,
},
{
"name": "suspend_node",
"description": "Suspend a node in a project",
+1 -1
View File
@@ -54,7 +54,7 @@ def get_symbol_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dict
return {"error": "symbol_id is required"}
download_url = f"{gns3_ctx['server_url']}/v3/symbols/{symbol_id}/raw"
username = gns3_ctx.get("jwt_username")
download_token = auth_service.create_access_token(username, expires_in=10) if username else None
download_token = auth_service.create_access_token(username, token_version=gns3_ctx.get("jwt_token_version", 0), expires_in=10) if username else None
result = {
"symbol_id": symbol_id,
"download_url": download_url,
+1
View File
@@ -356,6 +356,7 @@ class BaseManager:
raise ComputeError(f"Could not create an UDP connection to {rhost}:{rport}: {e}")
nio = NIOUDP(lport, rhost, rport)
nio.filters = nio_settings.get("filters", {})
nio.markers = nio_settings.get("markers", {})
nio.suspend = nio_settings.get("suspend", False)
elif nio_settings["type"] == "nio_tap":
tap_device = nio_settings["tap_device"]
+119
View File
@@ -935,9 +935,46 @@ class BaseNode:
f"Hypervisor {self._ubridge_hypervisor.host}:{self._ubridge_hypervisor.port} has successfully started"
)
await self._ubridge_hypervisor.connect()
# Tell this uBridge where to send MARK signals and which node id to
# tag them with. Marker is opt-in and inert until a `mark` filter is
# added, so this never disturbs the data plane.
await self._ubridge_configure_marker_sink()
# save if privileged are required in case uBridge needs to be restarted in self._ubridge_send()
self._ubridge_require_privileged_access = require_privileged_access
async def _ubridge_configure_marker_sink(self):
"""
Point this node's uBridge at the compute's marker UDP sink and tag its
signals with this node's id. Safe to call before any marker filter
exists — uBridge stays inert until a ``mark`` filter is configured.
Old uBridge builds without the marker module are tolerated: the failure
is downgraded to a warning so node start is not blocked by an opt-in
observability feature.
"""
from gns3server.compute.marker.marker_manager import MarkerManager
manager = MarkerManager.instance()
if not manager.running or not manager.host or not manager.port:
return
if self._ubridge_hypervisor is None:
return
try:
# Talk to the hypervisor directly, NOT via _ubridge_send: this runs
# inside _start_ubridge, which is reached THROUGH _ubridge_send when
# uBridge starts lazily (e.g. linking a stopped node). _ubridge_send's
# lock is non-reentrant, so calling it again here would deadlock on
# the held ___ubridge_send_lock. uBridge is already running and
# connected at this point, so the raw hypervisor send is safe.
await self._ubridge_hypervisor.send(f"marker sink {manager.host} {manager.port}")
await self._ubridge_hypervisor.send(f"marker node {self._id}")
except UbridgeError:
log.warning(
"uBridge does not support the marker module; traffic insight disabled for node %r",
self.name,
)
async def _stop_ubridge(self):
"""
Stops uBridge.
@@ -983,10 +1020,12 @@ class BaseNode:
await self._ubridge_send(f"bridge start {bridge_name}")
await self._ubridge_apply_filters(bridge_name, destination_nio.filters)
await self._ubridge_apply_markers(bridge_name, destination_nio)
async def update_ubridge_udp_connection(self, bridge_name, source_nio, destination_nio):
if destination_nio:
await self._ubridge_apply_filters(bridge_name, destination_nio.filters)
await self._ubridge_apply_markers(bridge_name, destination_nio)
async def ubridge_delete_bridge(self, name):
"""
@@ -1042,6 +1081,86 @@ class BaseNode:
)
i += 1
async def _ubridge_add_marker_filter(self, bridge_name, name, bpf, pcap_path, tag=None, link_id=None):
"""
Attach a `mark` packet filter to a uBridge bridge for traffic insight.
On BPF match uBridge (a) emits a UDP MARK signal to the configured sink
and (b) appends the packet to ``pcap_path``. Unlike the impairment
filters, this is an observability tap: it never drops or alters traffic,
and it is added/removed on its own (not via reset_packet_filters) so the
pcap is not closed/reopened on unrelated filter changes.
:param bridge_name: uBridge bridge carrying the link's traffic
:param name: stable, gns3server-chosen filter name (pcap identity + echoed in signals)
:param bpf: libpcap BPF expression
:param pcap_path: absolute path ubridge appends matched packets to
:param tag: optional correlation id echoed in MARK signals
"""
# mark <bpf> [tag <id>] [pcap <path>] — tag/pcap keyword pairs, any order.
# name travels from the controller REST layer (MarkerCreate schema) but is
# validated here too as defense-in-depth against hand-edited topology files.
# Note: "global-*" names are legitimate here — they come from project-level
# marker definitions (inherit_marker). The prefix is only forbidden at the
# user-facing schema layer, not at the uBridge boundary.
_MARKER_NAME_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_.-]*$")
if not _MARKER_NAME_RE.match(name):
raise UbridgeError(f"Invalid marker name: {name!r}")
cmd = 'bridge add_packet_filter {bridge} {name} mark "{bpf}"'.format(
bridge=bridge_name, name=name, bpf=bpf
)
if tag is not None:
cmd += f" tag {tag}"
# Per-link attribution (contract §3.2): when one ubridge bridge serves
# several GNS3 links (e.g. IOU's per-node bridge), bridge+filter collide,
# so the link id is the only way to tell signals — and pcap files — apart.
if link_id:
cmd += f" link {link_id}"
cmd += ' pcap "{path}"'.format(path=pcap_path)
# Let BPF compile errors propagate — the marker is the user's intent, so a
# bad expression must surface instead of being silently dropped.
await self._ubridge_send(cmd)
async def _ubridge_apply_markers(self, bridge_name, nio):
"""
(Re-)apply every traffic-insight marker carried by *nio* to the uBridge
bridge *bridge_name*. Called from ``add_ubridge_udp_connection`` (bridge
creation / node restart) and ``update_ubridge_udp_connection`` (NIO update
— the preceding ``_ubridge_apply_filters`` has already issued
``reset_packet_filters``, so we must re-add markers to survive the reset).
"""
from gns3server.compute.marker.marker_manager import MarkerManager
markers = nio.markers if hasattr(nio, 'markers') else {}
if not markers:
return
manager = MarkerManager.instance()
markers_dir = self.project.markers_working_directory()
for name, spec in markers.items():
bpf = spec.get("bpf", "")
tag = spec.get("tag")
link_id = spec.get("link_id", "")
pcap_path = os.path.join(
markers_dir, f"{self._id}_{link_id}_{name}.pcap"
)
try:
await self._ubridge_add_marker_filter(bridge_name, name, bpf, pcap_path, tag, link_id)
except UbridgeError as e:
# Swallow BPF compile errors (warn + skip) so a single bad
# expression can't break link creation / node restart — mirrors
# _ubridge_apply_filters, which does the same for packet filters.
if "syntax error" in str(e).lower() or "compile filter" in str(e).lower():
message = f"Warning: ignoring marker '{name}' due to BPF syntax error: {e}"
log.warning(message)
self.project.emit("log.warning", {"message": message})
continue
raise
manager.register(
str(self.project.id), self._id, name, link_id, tag
)
async def _add_ubridge_ethernet_connection(self, bridge_name, ethernet_interface, block_host_traffic=False):
"""
Creates a connection with an Ethernet interface in uBridge.
@@ -82,6 +82,9 @@ class Cloud(BaseNode):
host_interfaces = []
network_interfaces = gns3server.utils.interfaces.interfaces()
for interface in network_interfaces:
# Hide GNS3 internal bridges (e.g. EthernetSwitch kernel bridges)
if interface["name"].lower().startswith("gns3"):
continue
host_interfaces.append(
{
"name": interface["name"],
@@ -312,6 +315,7 @@ class Cloud(BaseNode):
)
await self._ubridge_apply_filters(bridge_name, nio.filters)
await self._ubridge_apply_markers(bridge_name, nio)
if port_info["type"] in ("ethernet", "tap"):
if not self.manager.has_privileged_access(self.ubridge_path):
@@ -452,6 +456,7 @@ class Cloud(BaseNode):
bridge_name = f"{self._id}-{port_number}"
if self._ubridge_hypervisor and self._ubridge_hypervisor.is_running():
await self._ubridge_apply_filters(bridge_name, nio.filters)
await self._ubridge_apply_markers(bridge_name, nio)
async def _delete_ubridge_connection(self, port_number):
"""
@@ -14,14 +14,45 @@
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
import asyncio
"""
Ethernet switch backed by a Linux kernel bridge driven through uBridge's
``brctl`` module.
The historical GNS3 Ethernet switch was an emulated L2 device inside Dynamips
(``ethsw``). This implementation replaces it with a *real* Linux kernel bridge:
one bridge per switch node, managed over uBridge's hypervisor socket. Each
switch port is a persistent TAP that plays two roles at once -- uBridge holds
its file descriptor as a ``nio_tap`` relay endpoint, and the same TAP is
enslaved to the kernel bridge as a port. This dual-role TAP is exactly the
pattern the Cloud node already uses for host bridges (see
``cloud.py::_add_linux_ethernet``).
Data path (UDP link mode)::
peer --UDP-- ubridge[nio_udp <-> nio_tap(tap)] --tap-- kernel bridge --tap-- ... (other ports)
The kernel bridge performs MAC learning/forwarding and VLAN filtering; uBridge
is only the per-port UDP transport (uBridge is strictly a 2-NIO pipe, it cannot
be the switch). ESW ``access``/``dot1q``/``qinq`` port modes are composed from
the ``brctl`` VLAN primitives here -- see ``_apply_port_vlan``.
"""
from ...base_node import BaseNode
from ...nios.nio_udp import NIOUDP
from ...error import NodeError
from gns3server.compute.ubridge.ubridge_error import UbridgeError
import logging
log = logging.getLogger(__name__)
# VLAN ethertypes the Linux kernel bridge can realise. ``brctl setvlanproto``
# accepts only 0x8100 (802.1Q) and 0x88a8 (802.1ad). The GNS3 schema also allows
# the legacy 0x9100/0x9200 QinQ ethertypes; the kernel bridge cannot do those, so
# configuring them on a qinq port is rejected.
_SUPPORTED_VLAN_ETHERTYPE = {"0x8100", "0x88a8"}
_QINQ_ETHERTYPE = "0x88a8"
class EthernetSwitch(BaseNode):
@@ -32,11 +63,101 @@ class EthernetSwitch(BaseNode):
:param node_id: Node identifier
:param project: Project instance
:param manager: Parent VM Manager
:param ports: initial switch ports
"""
def __init__(self, name, node_id, project, manager):
def __init__(self, name, node_id, project, manager, console=None, console_type=None, ports=None):
super().__init__(name, node_id, project, manager)
super().__init__(name, node_id, project, manager, console=console, console_type=console_type or "none")
# The switch has no console; ``console_type="none"`` makes BaseNode skip
# reserving a TCP console port entirely.
self._ubridge_require_privileged_access = True
self._nios = {}
self._tap_by_port = {} # port_number -> kernel TAP enslaved to the bridge
self._bridge_name = None # kernel bridge interface name (allocated on start)
self._bridge_created = False
self._bridge_proto_set = False # whether ``brctl setvlanproto`` has been applied
# Idempotency flag for start(). Decoupled from ``status`` so the node can
# report "started" (always-on, like the ESW) while ``duplicate_node`` still
# sees status "stopped" and refuses only genuinely running stateful nodes.
self._started = False
if ports is None:
# 8 access ports in VLAN 1 by default, matching the historical ESW.
self._ports_mapping = []
for port_number in range(0, 8):
self._ports_mapping.append(
{"port_number": port_number, "name": f"Ethernet{port_number}", "type": "access", "vlan": 1}
)
else:
self._ports_mapping = self._normalize_ports(ports)
# ------------------------------------------------------------------ #
# helpers
# ------------------------------------------------------------------ #
@staticmethod
def _normalize_ports(ports):
"""Assign sequential port numbers/names like the Dynamips ESW did."""
port_number = 0
normalized = []
for port in ports:
port = dict(port)
port["name"] = f"Ethernet{port_number}"
port["port_number"] = port_number
normalized.append(port)
port_number += 1
return normalized
def _ubridge_bridge_name(self, port_number):
"""Name of the per-port uBridge relay bridge (not a kernel interface)."""
return f"{self._id}-{port_number}"
def _tap_name(self, port_number):
"""Kernel TAP name for a port: ``<bridge>-<port>`` (host-unique via the bridge)."""
return f"{self._bridge_name}-{port_number}"
def _port_settings(self, port_number):
for port in self._ports_mapping:
if port["port_number"] == port_number:
return port
return None
# ------------------------------------------------------------------ #
# properties / serialisation
# ------------------------------------------------------------------ #
@property
def nios(self):
return self._nios
@property
def ports_mapping(self):
return self._ports_mapping
@ports_mapping.setter
def ports_mapping(self, ports):
if ports != self._ports_mapping:
if len(self._nios) > 0 and len(ports) != len(self._ports_mapping):
raise NodeError("Cannot change the port count of a switch that is already connected.")
self._ports_mapping = self._normalize_ports(ports)
@property
def console(self):
return self._console
@console.setter
def console(self, console):
self._console = console
@property
def console_type(self):
return self._console_type
@console_type.setter
def console_type(self, console_type):
self._console_type = console_type
def asdict(self):
@@ -44,61 +165,375 @@ class EthernetSwitch(BaseNode):
"name": self.name,
"usage": self.usage,
"node_id": self.id,
"project_id": self.project.id
"project_id": self.project.id,
"ports_mapping": self._ports_mapping,
"console": self.console,
"console_type": self.console_type,
# The switch is always-on once created (a kernel bridge), like the ESW.
"status": "started",
}
# ------------------------------------------------------------------ #
# lifecycle
# ------------------------------------------------------------------ #
async def create(self):
"""
Creates this switch.
"""
super().create()
await self.start()
log.info(f'Ethernet switch "{self._name}" [{self._id}] has been created')
async def start(self):
"""
Starts this switch: bring up uBridge, create the kernel bridge, and
re-wire any ports already bound before a restart.
"""
if not self._started:
if self._ubridge_hypervisor and self._ubridge_hypervisor.is_running():
await self._stop_ubridge()
await self._start_ubridge(self._ubridge_require_privileged_access)
await self._ensure_bridge()
for port_number in self._nios:
if self._nios[port_number]:
try:
await self._add_ubridge_connection(self._nios[port_number], port_number)
except (UbridgeError, NodeError) as e:
self._started = False
raise e
self._started = True
async def _ensure_bridge(self):
"""
Creates the per-node kernel bridge once and enables VLAN filtering.
Applies the bridge-level QinQ ethertype if any port needs it.
The bridge name is deterministic: ``gns3`` + the first 6 hex chars of
this switch's UUID (kernel interface names are ≤ 15 chars). A stale
bridge from a previous crash is deleted first so ``brctl create`` never
hits EEXIST.
"""
if self._bridge_created:
return
# deterministic short name — 10 chars, always fits the 15-char kernel cap
self._bridge_name = "gns3" + self._id.replace("-", "")[:6]
# crash recovery: best-effort delete any leftover bridge
try:
await self._ubridge_send(f'brctl delete "{self._bridge_name}"')
except UbridgeError:
pass # not found = nothing to clean
await self._ubridge_send(f'brctl create "{self._bridge_name}"')
# ``brctl create`` leaves the bridge DOWN; bring it UP so it forwards.
await self._ubridge_send(f'link set "{self._bridge_name}" up')
await self._ubridge_send(f'brctl vlanfiltering "{self._bridge_name}" on')
self._bridge_created = True
await self._apply_bridge_proto_if_needed()
async def _apply_bridge_proto_if_needed(self):
"""
If any port is a QinQ port using the 802.1ad ethertype (0x88a8), switch
the whole bridge to that protocol. A Linux bridge has a single VLAN
protocol, so mixed QinQ ethertypes within one switch are not supported.
"""
proto = None
for port in self._ports_mapping:
if port.get("type") == "qinq":
# normalise case: the schema carries uppercase (e.g. "0x88A8") but
# brctl setvlanproto wants lowercase hex
ethertype = port.get("ethertype", "0x8100").lower()
if ethertype not in _SUPPORTED_VLAN_ETHERTYPE:
raise NodeError(
f"VLAN ethertype {ethertype} is not supported by the Linux bridge "
f"(only 0x8100/0x88a8) for QinQ port {port['name']}"
)
if ethertype == _QINQ_ETHERTYPE:
proto = _QINQ_ETHERTYPE
if proto and not self._bridge_proto_set:
await self._ubridge_send(f'brctl setvlanproto "{self._bridge_name}" {proto}')
self._bridge_proto_set = True
async def delete(self):
"""
Deletes this switch.
"""
raise NotImplementedError()
return await self.close()
async def close(self):
"""
Closes this switch: release UDP ports, tear down the kernel bridge, stop uBridge.
"""
if not (await super().close()):
return False
for nio in self._nios.values():
if nio and isinstance(nio, NIOUDP):
self.manager.port_manager.release_udp_port(nio.lport, self._project)
self._nios.clear()
self._tap_by_port.clear()
if self._ubridge_hypervisor and self._ubridge_hypervisor.is_running() and self._bridge_created:
try:
# Deleting the bridge releases its enslaved TAPs; uBridge destroys
# them when it stops below.
await self._ubridge_send(f'brctl delete "{self._bridge_name}"')
except UbridgeError as e:
log.warning(f'Could not delete kernel bridge "{self._bridge_name}": {e}')
self._bridge_created = False
self._bridge_proto_set = False
self._bridge_name = None
self._started = False
await self._stop_ubridge()
log.info(f'Ethernet switch "{self._name}" [{self._id}] has been closed')
return True
# ------------------------------------------------------------------ #
# per-port wiring
# ------------------------------------------------------------------ #
async def add_nio(self, nio, port_number):
"""
Adds a NIO as new port on this switch.
Adds a NIO as a new port on this switch.
:param nio: NIO instance to add
:param port_number: port to allocate for the NIO
"""
raise NotImplementedError()
if port_number in self._nios:
raise NodeError(f"Port {port_number} isn't free")
if not isinstance(nio, NIOUDP):
raise NodeError("Ethernet switch ports only support UDP NIOs")
log.info(
'Ethernet switch "{name}" [{id}]: NIO {nio} bound to port {port}'.format(
name=self._name, id=self._id, nio=nio, port=port_number
)
)
try:
await self.start()
await self._add_ubridge_connection(nio, port_number)
self._nios[port_number] = nio
except (NodeError, UbridgeError) as e:
log.error('Cannot add NIO on Ethernet switch "{name}": {error}'.format(name=self._name, error=e))
await self._stop_ubridge()
self.status = "stopped"
self._nios[port_number] = nio
self.project.emit("log.error", {"message": str(e)})
async def _add_ubridge_connection(self, nio, port_number):
"""
Wires one port: a per-port uBridge relay (nio_tap <-> nio_udp) whose TAP
is enslaved to the kernel bridge, with the port's VLAN mode applied.
"""
port_settings = self._port_settings(port_number)
if port_settings is None:
raise NodeError(f"Port {port_number} doesn't exist on Ethernet switch '{self.name}'")
ubridge_bridge = self._ubridge_bridge_name(port_number)
tap = self._tap_name(port_number)
# per-port uBridge relay -- uBridge holds the TAP fd
await self._ubridge_send(f"bridge create {ubridge_bridge}")
await self._ubridge_send(f'bridge add_nio_tap {ubridge_bridge} "{tap}"')
# enslave the same TAP to the kernel bridge (the cloud.py::_add_linux_ethernet move)
await self._ubridge_send(f'brctl addif "{self._bridge_name}" "{tap}"')
# VLAN membership for this port's access/trunk/qinq mode
await self._apply_port_vlan(port_settings, tap)
# GNS3 link endpoint
await self._ubridge_send(
"bridge add_nio_udp {name} {lport} {rhost} {rport}".format(
name=ubridge_bridge, lport=nio.lport, rhost=nio.rhost, rport=nio.rport
)
)
await self._ubridge_apply_filters(ubridge_bridge, nio.filters)
await self._ubridge_apply_markers(ubridge_bridge, nio)
if nio.capturing:
await self._ubridge_send(
'bridge start_capture {name} "{output_file}"'.format(
name=ubridge_bridge, output_file=nio.pcap_output_file
)
)
await self._ubridge_send(f"bridge start {ubridge_bridge}")
self._tap_by_port[port_number] = tap
async def _delete_ubridge_connection(self, port_number):
"""
Tears down one port's wiring: release the TAP from the bridge and delete
the per-port uBridge relay.
"""
tap = self._tap_by_port.pop(port_number, None)
ubridge_bridge = self._ubridge_bridge_name(port_number)
if tap is not None and self._bridge_created:
try:
await self._ubridge_send(f'brctl delif "{self._bridge_name}" "{tap}"')
except UbridgeError as e:
log.warning(f'Could not remove TAP "{tap}" from bridge "{self._bridge_name}": {e}')
try:
await self._ubridge_send(f"bridge delete {ubridge_bridge}")
except UbridgeError as e:
log.warning(f"Could not delete uBridge bridge {ubridge_bridge}: {e}")
async def remove_nio(self, port_number):
"""
Removes the specified NIO as member of this switch.
Removes the specified NIO from this switch.
:param port_number: allocated port number
:returns: the NIO that was bound to the allocated port
"""
raise NotImplementedError()
if port_number not in self._nios:
raise NodeError(f"Port {port_number} is not allocated")
await self.stop_capture(port_number)
nio = self._nios[port_number]
if isinstance(nio, NIOUDP):
self.manager.port_manager.release_udp_port(nio.lport, self._project)
log.info(
'Ethernet switch "{name}" [{id}]: NIO {nio} removed from port {port}'.format(
name=self._name, id=self._id, nio=nio, port=port_number
)
)
del self._nios[port_number]
if self._ubridge_hypervisor and self._ubridge_hypervisor.is_running():
await self._delete_ubridge_connection(port_number)
return nio
def get_nio(self, port_number):
"""
Gets a port NIO binding.
:param port_number: port number
:returns: NIO instance
"""
if port_number not in self._nios:
raise NodeError(f"Port {port_number} is not connected")
return self._nios[port_number]
async def update_nio(self, port_number, nio):
"""
Re-applies uBridge filters/markers for a port (called when a link is updated).
"""
ubridge_bridge = self._ubridge_bridge_name(port_number)
if self._ubridge_hypervisor and self._ubridge_hypervisor.is_running():
await self._ubridge_apply_filters(ubridge_bridge, nio.filters)
await self._ubridge_apply_markers(ubridge_bridge, nio)
# ------------------------------------------------------------------ #
# VLAN mode translation
# ------------------------------------------------------------------ #
async def _reset_port_vlan(self, tap):
"""
Resets a port's VLAN membership to the kernel default (PVID 1, untagged)
by re-enslaving it. Used before re-applying a changed mode so stale VIDs
from the previous mode do not leak.
"""
await self._ubridge_send(f'brctl delif "{self._bridge_name}" "{tap}"')
await self._ubridge_send(f'brctl addif "{self._bridge_name}" "{tap}"')
async def _apply_port_vlan(self, port_settings, tap):
"""
Translates an ESW port mode into ``brctl`` VLAN primitives. The port must
already be enslaved to the bridge and carry the default PVID 1.
- access VLAN N: drop default 1, add N as PVID + egress untagged.
- dot1q trunk (native N): drop default 1, admit all VIDs tagged, then mark
the native VLAN PVID + untagged. (The ESW model declares only the native
VLAN per trunk port, so the trunk admits all VIDs, like the emulated ESW.)
- qinq (outer N): the bridge-level protocol is set separately; the port
gets the service VLAN as PVID + untagged so customer frames are S-tagged.
"""
br = self._bridge_name
port_type = port_settings["type"]
vlan = int(port_settings["vlan"])
if port_type == "access":
await self._ubridge_send(f'brctl vlan_del "{br}" "{tap}" 1')
await self._ubridge_send(f'brctl vlan_add "{br}" "{tap}" {vlan} pvid untagged')
elif port_type == "dot1q":
await self._ubridge_send(f'brctl vlan_del "{br}" "{tap}" 1')
await self._ubridge_send(f'brctl vlan_add "{br}" "{tap}" 1 vid 4094')
await self._ubridge_send(f'brctl vlan_add "{br}" "{tap}" {vlan} pvid untagged')
elif port_type == "qinq":
# setvlanproto is applied at the bridge level by _apply_bridge_proto_if_needed
await self._ubridge_send(f'brctl vlan_del "{br}" "{tap}" 1')
await self._ubridge_send(f'brctl vlan_add "{br}" "{tap}" {vlan} pvid untagged')
else:
raise NodeError(f"Unknown port type '{port_type}' on Ethernet switch '{self.name}'")
async def update_port_settings(self):
"""
Re-applies port settings (called after ``ports_mapping`` is updated). For
ports already wired, reset then re-apply so a mode/VLAN change fully
replaces the previous VLAN membership.
"""
await self._apply_bridge_proto_if_needed()
if not (self._ubridge_hypervisor and self._ubridge_hypervisor.is_running() and self._bridge_created):
return
for port_settings in self._ports_mapping:
port_number = port_settings["port_number"]
tap = self._tap_by_port.get(port_number)
if tap is None:
continue
await self._reset_port_vlan(tap)
await self._apply_port_vlan(port_settings, tap)
# ------------------------------------------------------------------ #
# capture
# ------------------------------------------------------------------ #
async def start_capture(self, port_number, output_file, data_link_type="DLT_EN10MB"):
"""
Starts a packet capture.
Starts a packet capture on a port (uBridge captures on the per-port relay).
:param port_number: allocated port number
:param output_file: PCAP destination file for the capture
:param data_link_type: PCAP data link type (DLT_*), default is DLT_EN10MB
"""
raise NotImplementedError()
nio = self.get_nio(port_number)
if nio.capturing:
raise NodeError(f"Packet capture is already activated on port {port_number}")
nio.start_packet_capture(output_file)
if self._ubridge_hypervisor and self._ubridge_hypervisor.is_running():
ubridge_bridge = self._ubridge_bridge_name(port_number)
await self._ubridge_send(f'bridge start_capture {ubridge_bridge} "{output_file}"')
log.info(
'Ethernet switch "{name}" [{id}]: starting packet capture on port {port}'.format(
name=self.name, id=self.id, port=port_number
)
)
async def stop_capture(self, port_number):
"""
Stops a packet capture.
Stops a packet capture on a port.
:param port_number: allocated port number
"""
raise NotImplementedError()
nio = self.get_nio(port_number)
if not nio.capturing:
return
nio.stop_packet_capture()
if self._ubridge_hypervisor and self._ubridge_hypervisor.is_running():
ubridge_bridge = self._ubridge_bridge_name(port_number)
await self._ubridge_send(f"bridge stop_capture {ubridge_bridge}")
log.info(
'Ethernet switch "{name}" [{id}]: stopping packet capture on port {port}'.format(
name=self.name, id=self.id, port=port_number
)
)
+18
View File
@@ -87,6 +87,23 @@ class Nat(Cloud):
return True
def asdict(self):
nat_interface = self._ports_mapping[0].get("interface", "") if self._ports_mapping else ""
host_interfaces = []
network_interfaces = gns3server.utils.interfaces.interfaces()
for interface in network_interfaces:
if interface["name"] == nat_interface:
host_interfaces.append(
{
"name": interface["name"],
"type": interface["type"],
"special": interface["special"],
"ip_addresses": interface.get("ip_addresses", []),
}
)
break
return {
"name": self.name,
"usage": self.usage,
@@ -94,4 +111,5 @@ class Nat(Cloud):
"project_id": self.project.id,
"status": "started",
"ports_mapping": self.ports_mapping,
"interfaces": host_interfaces,
}
+2 -1
View File
@@ -1228,6 +1228,7 @@ class DockerVM(BaseNode):
)
await self._ubridge_send(f"bridge start {bridge_name}")
await self._ubridge_apply_filters(bridge_name, nio.filters)
await self._ubridge_apply_markers(bridge_name, nio)
async def adapter_add_nio_binding(self, adapter_number, nio):
"""
@@ -1268,7 +1269,7 @@ class DockerVM(BaseNode):
bridge_name = f"bridge{adapter_number}"
if bridge_name in self._bridges:
await self._ubridge_apply_filters(bridge_name, nio.filters)
await self._ubridge_apply_markers(bridge_name, nio)
async def adapter_remove_nio_binding(self, adapter_number):
"""
Removes an adapter NIO binding.
+1
View File
@@ -376,6 +376,7 @@ class Dynamips(BaseManager):
raise DynamipsError(f"Could not create an UDP connection to {rhost}:{rport}: {e}")
nio = NIOUDP(node, lport, rhost, rport)
nio.filters = nio_settings.get("filters", {})
nio.markers = nio_settings.get("markers", {})
nio.suspend = nio_settings.get("suspend", False)
elif nio_settings["type"] == "nio_generic_ethernet":
ethernet_device = nio_settings["ethernet_device"]
+21
View File
@@ -40,6 +40,7 @@ class NIO:
self._hypervisor = hypervisor
self._name = name
self._filters = {}
self._markers = {}
self._suspended = False
self._capturing = False
self._pcap_output_file = ""
@@ -303,6 +304,26 @@ class NIO:
assert isinstance(new_filters, dict)
self._filters = new_filters
@property
def markers(self):
"""
Returns the list of traffic-insight markers for this NIO.
:returns: markers (dictionary)
"""
return self._markers
@markers.setter
def markers(self, new_markers):
"""
Set markers for this NIO.
:param new_markers: markers (dictionary)
"""
self._markers = new_markers
@property
def capturing(self):
"""
@@ -82,10 +82,12 @@ class NIOUDP(NIO):
self._source_nio = nio_udp.NIOUDP(self._local_tunnel_rport, "127.0.0.1", self._local_tunnel_lport)
self._destination_nio = nio_udp.NIOUDP(self._lport, self._rhost, self._rport)
self._destination_nio.filters = self._filters
self._destination_nio.markers = self._markers
await self._node.add_ubridge_udp_connection(self._bridge_name, self._source_nio, self._destination_nio)
async def update(self):
self._destination_nio.filters = self._filters
self._destination_nio.markers = self._markers
await self._node.update_ubridge_udp_connection(self._bridge_name, self._source_nio, self._destination_nio)
async def close(self):
+61
View File
@@ -746,6 +746,7 @@ class IOUVM(BaseNode):
)
await self._ubridge_apply_filters(bay_id, unit_id, nio.filters)
await self._ubridge_apply_markers(bay_id, unit_id, nio)
unit_id += 1
bay_id += 1
@@ -1067,6 +1068,7 @@ class IOUVM(BaseNode):
)
)
await self._ubridge_apply_filters(adapter_number, port_number, nio.filters)
await self._ubridge_apply_markers(adapter_number, port_number, nio)
async def adapter_update_nio_binding(self, adapter_number, port_number, nio):
"""
@@ -1079,6 +1081,7 @@ class IOUVM(BaseNode):
if self.ubridge:
await self._ubridge_apply_filters(adapter_number, port_number, nio.filters)
await self._ubridge_apply_markers(adapter_number, port_number, nio)
async def _ubridge_apply_filters(self, adapter_number, port_number, filters):
"""
@@ -1095,6 +1098,64 @@ class IOUVM(BaseNode):
cmd = "iol_bridge add_packet_filter {} {}".format(location, filter)
await self._ubridge_send(cmd)
async def _ubridge_apply_markers(self, adapter_number, port_number, nio):
"""
(Re-)apply traffic-insight markers to the IOL bridge.
IOU uses ``iol_bridge`` (not ``bridge``) and the ``add_packet_filter``
command carries extra ``{bay} {unit}`` positional arguments between the
bridge name and the filter name — this override mirrors the pattern in
``_ubridge_apply_filters`` above.
:param adapter_number: bay id
:param port_number: unit id
:param nio: NIO instance carrying ``nio.markers``
"""
from gns3server.compute.marker.marker_manager import MarkerManager
markers = nio.markers if hasattr(nio, 'markers') else {}
if not markers:
return
manager = MarkerManager.instance()
markers_dir = self.project.markers_working_directory()
bridge_name = f"IOL-BRIDGE-{self.application_id + 512}"
location = "{bridge_name} {bay} {unit}".format(
bridge_name=bridge_name, bay=adapter_number, unit=port_number
)
for name, spec in markers.items():
bpf = spec.get("bpf", "")
tag = spec.get("tag")
link_id = spec.get("link_id", "")
pcap_path = os.path.join(
markers_dir, f"{self._id}_{link_id}_{name}.pcap"
)
# Build the iol_bridge marker filter command:
# iol_bridge add_packet_filter {br} {bay} {unit} {name} mark "{bpf}" [tag {id}] pcap "{path}"
cmd = 'iol_bridge add_packet_filter {loc} {name} mark "{bpf}"'.format(
loc=location, name=name, bpf=bpf
)
if tag is not None:
cmd += f" tag {tag}"
# IOU uses one per-node IOL-BRIDGE for every link, so bridge+filter
# are identical across this node's links — `link` is the only way the
# controller can tell their signals apart (contract §3.2).
if link_id:
cmd += f" link {link_id}"
cmd += ' pcap "{path}"'.format(path=pcap_path)
try:
await self._ubridge_send(cmd)
except UbridgeError as e:
if "syntax error" in str(e).lower() or "compile filter" in str(e).lower():
message = f"Warning: ignoring marker '{name}' due to BPF syntax error: {e}"
log.warning(message)
self.project.emit("log.warning", {"message": message})
continue
raise
manager.register(
str(self.project.id), self._id, name, link_id, tag
)
async def adapter_remove_nio_binding(self, adapter_number, port_number):
"""
Removes an adapter NIO binding.
+24
View File
@@ -0,0 +1,24 @@
#!/usr/bin/env python
#
# Copyright (C) 2024 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 <http://www.gnu.org/licenses/>.
#
#
# Traffic-insight marker subsystem (compute side).
#
# ubridge's ``marker`` module is a passive tap: on a BPF match it emits a UDP
# ``MARK`` signal to a configured sink and/or appends the packet to a pcap.
# This package owns the compute-side UDP sink: one listener per compute process
# serves every ubridge on that host, disambiguated by ``node=<id>``.
@@ -0,0 +1,109 @@
#!/usr/bin/env python
#
# Copyright (C) 2024 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 <http://www.gnu.org/licenses/>.
import asyncio
import logging
log = logging.getLogger(__name__)
class MarkerListener(asyncio.DatagramProtocol):
"""
Receives ubridge ``MARK`` signal datagrams and turns each into a
``marker.match`` notification.
Signal format (one datagram per match, ASCII)::
MARK <sec.usec> node=<id> filter=<name> tag=<tag> len=<n>\\n
The signal carries metadata only (no packet bytes). The compute-side
:class:`~gns3server.compute.marker.marker_manager.MarkerManager` registry
resolves ``(node_id, filter_name)`` to ``(project_id, link_id, tag)`` so the
event can be emitted on the right project-scoped notification stream.
"""
def __init__(self, manager):
# MarkerManager owns this listener and the registry.
self._manager = manager
self.transport = None
def connection_made(self, transport):
self.transport = transport
def datagram_received(self, data, addr):
try:
self._handle(data)
except Exception:
# Never let a malformed datagram kill the listener.
log.exception("Failed to process MARK datagram from %s: %r", addr, data)
def _handle(self, data):
line = data.decode("utf-8", errors="replace").strip()
if not line.startswith("MARK"):
return
parts = line.split()
# parts[0] == "MARK"; parts[1] == "<sec.usec>"
if len(parts) < 2:
return
try:
ts = float(parts[1])
except ValueError:
log.warning("Ignoring MARK signal with bad timestamp: %r", line)
return
kv = {}
for token in parts[2:]:
if "=" in token:
key, value = token.split("=", 1)
kv[key] = value
node_id = kv.get("node")
filter_name = kv.get("filter")
if not node_id or not filter_name:
return
# "-" means the field was unset on the ubridge side (see contract §3.3).
link = kv.get("link")
tag = kv.get("tag")
length = kv.get("len")
project_id, link_id, registered_tag = self._manager.lookup(node_id, filter_name)
if project_id is None:
log.warning(
"MARK signal for unregistered node=%s filter=%s, dropping", node_id, filter_name
)
return
# `link=` is the authoritative per-link id (opaque, set by gns3server at
# filter install time). It disambiguates signals that share a node+filter
# across several links; fall back to the registry's link only for legacy
# signals that carry no `link=`.
signal_link = link if link and link != "-" else None
event = {
"project_id": project_id,
"node_id": node_id,
"link_id": signal_link or link_id,
"filter": filter_name,
# Prefer the value carried in the signal; fall back to the one we registered.
"tag": tag if tag and tag != "-" else registered_tag,
"ts": ts,
"len": int(length) if length and length.isdigit() else 0,
}
self._manager.emit_match(project_id, event)
+185
View File
@@ -0,0 +1,185 @@
#!/usr/bin/env python
#
# Copyright (C) 2024 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 <http://www.gnu.org/licenses/>.
import asyncio
import logging
from gns3server.compute.marker.marker_listener import MarkerListener
from gns3server.compute.notification_manager import NotificationManager
log = logging.getLogger(__name__)
class MarkerManager:
"""
Singleton owning the compute-side UDP sink for ubridge ``MARK`` signals and
the registry that maps each ``(node_id, filter_name)`` back to its
``(project_id, link_id, tag)``.
The registry is populated when a marker is created on a link (the compute
endpoint has project_id + node_id from its route path and link_id/name/tag
from the request body) and cleared when the marker is deleted or the project
closed. At signal time it is an O(1) lookup — no node-table scan, and the
signal payload is untouched.
One listener per compute process serves every ubridge on that host; source
ubridges are disambiguated by ``node=<id>`` (UUID, globally unique).
"""
def __init__(self):
self._listener = None
self._transport = None
self._host = None
self._port = None
# Flat lookup: (node_id, filter_name) -> {"project_id", "link_id", "tag"}
self._entries = {}
# Reverse index for O(1) per-project teardown: project_id -> set of keys
self._by_project = {}
@property
def host(self):
"""The host the UDP sink is reachable on (for ``marker sink``)."""
return self._host
@property
def port(self):
"""The UDP port the sink is bound on (for ``marker sink``)."""
return self._port
@property
def running(self):
return self._transport is not None
async def start(self, host="127.0.0.1", port=0):
"""
Bind the UDP sink. ``port=0`` lets the OS choose a free port, which is
then read back and exposed via :attr:`port` for ``marker sink`` commands.
"""
if self.running:
return
loop = asyncio.get_running_loop()
self._listener = MarkerListener(self)
try:
self._transport, _ = await loop.create_datagram_endpoint(
lambda: self._listener, local_addr=(host, port)
)
except OSError:
if port != 0:
log.warning(
"Marker listener: port %s unavailable, falling back to OS-assigned port", port
)
try:
self._transport, _ = await loop.create_datagram_endpoint(
lambda: self._listener, local_addr=(host, 0)
)
except OSError as e:
log.error(
"Marker listener startup failed: %s. Traffic insight signals are unavailable.", e
)
self._listener = None
return
else:
log.error(
"Marker listener startup failed on OS-assigned port. Traffic insight signals are unavailable."
)
self._listener = None
return
sock = self._transport.get_extra_info("socket")
self._host = host
self._port = sock.getsockname()[1] if sock else port
log.info("Marker signal sink listening on %s:%s", self._host, self._port)
async def stop(self):
"""Close the UDP sink and drop the whole registry."""
if self._transport:
self._transport.close()
self._transport = None
self._listener = None
self._entries.clear()
self._by_project.clear()
self._host = None
self._port = None
def register(self, project_id, node_id, filter_name, link_id, tag=None):
"""
Record that ``filter_name`` on ``node_id`` belongs to ``project_id`` /
``link_id``. Called from the compute marker-start endpoint.
Re-registering the same key updates the stored tag (e.g. on re-add).
"""
key = (node_id, filter_name)
self._entries[key] = {"project_id": project_id, "link_id": link_id, "tag": tag}
self._by_project.setdefault(project_id, set()).add(key)
def unregister(self, node_id, filter_name):
"""Forget a single marker. Returns True if something was removed."""
key = (node_id, filter_name)
entry = self._entries.pop(key, None)
if entry is None:
return False
project_entries = self._by_project.get(entry["project_id"])
if project_entries is not None:
project_entries.discard(key)
if not project_entries:
self._by_project.pop(entry["project_id"], None)
return True
def unregister_project(self, project_id):
"""Drop every marker belonging to ``project_id`` (project close)."""
keys = self._by_project.pop(project_id, None)
if not keys:
return
for key in keys:
self._entries.pop(key, None)
def lookup(self, node_id, filter_name):
"""
O(1) resolution of an incoming signal to its project/link/tag.
:returns: (project_id, link_id, tag) or (None, None, None) on miss.
"""
entry = self._entries.get((node_id, filter_name))
if entry is None:
return None, None, None
return entry["project_id"], entry["link_id"], entry["tag"]
def emit_match(self, project_id, event):
"""
Forward a parsed match as a project-scoped ``marker.match`` notification.
Flows compute -> controller dispatch -> project_emit -> web UI WS.
"""
NotificationManager.instance().emit("marker.match", event, project_id=project_id)
_instance = None
@staticmethod
def instance():
if MarkerManager._instance is None:
MarkerManager._instance = MarkerManager()
return MarkerManager._instance
@staticmethod
def reset():
MarkerManager._instance = None
+22
View File
@@ -30,6 +30,7 @@ class NIO:
self._capturing = False
self._suspended = False
self._filters = {}
self._markers = {}
self._pcap_output_file = ""
self._pcap_data_link_type = ""
@@ -118,3 +119,24 @@ class NIO:
assert isinstance(new_filters, dict)
self._filters = new_filters
@property
def markers(self):
"""
Returns the traffic-insight markers for this NIO.
:returns: markers (dictionary: name -> {bpf, tag, link_id})
"""
return self._markers
@markers.setter
def markers(self, new_markers):
"""
Set the traffic-insight markers for this NIO.
:param new_markers: markers (dictionary: name -> {bpf, tag, link_id})
"""
assert isinstance(new_markers, dict)
self._markers = new_markers
+2 -1
View File
@@ -80,5 +80,6 @@ class NIOUDP(NIO):
"rport": self._rport,
"rhost": self._rhost,
"suspend": self._suspended,
"filters": self._filters
"filters": self._filters,
"markers": self._markers
}
+16
View File
@@ -246,6 +246,22 @@ class Project:
raise ComputeError(f"Could not create the capture working directory: {e}")
return workdir
def markers_working_directory(self):
"""
Returns the working directory where uBridge writes per-link marker pcaps
(matched packets, kept for later replay).
:returns: path to the directory
"""
workdir = os.path.join(self._path, "project-files", "markers")
if not self._deleted:
try:
os.makedirs(workdir, exist_ok=True)
except OSError as e:
raise ComputeError(f"Could not create the markers working directory: {e}")
return workdir
def add_node(self, node):
"""
Adds a node to the project.
@@ -92,6 +92,13 @@ udp_end_port_range = 30000
; uBridge executable location, default: search in PATH
;ubridge_path = ubridge
; Marker (traffic-insight) UDP sink: one listener per compute process that
; receives uBridge MARK signals from every uBridge on this host.
; marker_listen_host defaults to 127.0.0.1 because uBridge runs locally.
; marker_listen_port defaults to 3070 (set to 0 for OS-chosen).
;marker_listen_host = 127.0.0.1
;marker_listen_port = 3070
; Option to enable or disable compute HTTP authentication
enable_http_auth = True
+3
View File
@@ -741,6 +741,9 @@ class Controller:
topo_data.pop("version")
topo_data.pop("revision")
topo_data.pop("type")
# marker_definitions is restored by Project.open() from the topology
# file; it must not be passed to Project.__init__.
topo_data.pop("marker_definitions", None)
if topo_data["project_id"] in self._projects:
project = self._projects[topo_data["project_id"]]
+58
View File
@@ -88,6 +88,7 @@ class Link:
self._link_type = "ethernet"
self._suspended = False
self._filters = {}
self._markers = {}
self._link_style = {}
self._wireshark = False
self._show_filters_icon = True
@@ -99,6 +100,40 @@ class Link:
"""
return self._filters
@property
def markers(self):
"""
Get the traffic insight markers dict: name → {bpf, tag, enabled}
"""
return self._markers
async def inherit_marker(self, def_name, marker_def):
"""
Apply a project-level marker definition to this link.
The marker is stored under ``global-{def_name}`` so it cannot collide
with a per-link private marker of the same name. It carries an
``inherited_from`` back-reference that (a) guards against per-link
edits and (b) lets the project sync changes to every copy at once.
"""
await self.start_marker(
name=f"global-{def_name}",
bpf=marker_def["bpf"],
tag=marker_def.get("tag"),
color=marker_def.get("color"),
highlight_duration=marker_def.get("highlight_duration"),
inherited_from=def_name,
)
def _persist_markers(self):
"""
Return only the per-link (non-inherited) markers suitable for
persistence in a topology dump. Inherited markers are re-created from
``project._marker_definitions`` on load so they do not need to be saved.
"""
return {k: v for k, v in self._markers.items() if not v.get("inherited_from")}
@property
def show_filters_icon(self):
"""
@@ -298,6 +333,27 @@ class Link:
raise NotImplementedError
async def start_marker(self, name, bpf, tag=None):
"""
Attach a traffic-insight marker to this link (base — UDPLink overrides).
"""
raise NotImplementedError
async def stop_marker(self, name):
"""
Remove a traffic-insight marker from this link (base — UDPLink overrides).
"""
raise NotImplementedError
async def update_marker(self, name, bpf=None, tag=None, enabled=None):
"""
Update an existing marker's BPF, tag, or enabled flag.
A BPF change is a delete+re-add on the ubridge side so the pcap is
flushed and the new filter takes effect.
"""
raise NotImplementedError
async def start_capture(self, data_link_type="DLT_EN10MB", capture_file_name=None, wireshark=False, jwt_token=None):
"""
Start capture on the link
@@ -571,6 +627,7 @@ class Link:
"nodes": res,
"link_id": self._id,
"filters": self._filters,
"markers": self._persist_markers(),
"link_style": self._link_style,
"suspend": self._suspended,
"show_filters_icon": getattr(self, '_show_filters_icon', True),
@@ -585,6 +642,7 @@ class Link:
"capture_compute_id": self.capture_compute_id,
"link_type": self._link_type,
"filters": self._filters,
"markers": self._markers,
"suspend": self._suspended,
"link_style": self._link_style,
"wireshark": self._wireshark,
+8
View File
@@ -639,6 +639,14 @@ class Node:
except asyncio.TimeoutError:
raise ControllerTimeoutError(f"Timeout when reset console {self._name}")
async def get(self, path="", **kwargs):
"""
HTTP get on the node
"""
return await self._compute.get(
f"/projects/{self._project.id}/{self._node_type}/nodes/{self._id}{path}", **kwargs
)
async def post(self, path, data=None, **kwargs):
"""
HTTP post on the node
+179
View File
@@ -41,6 +41,7 @@ from ..config import Config
from ..utils.path import check_path_allowed, get_default_project_directory
from ..utils.application_id import get_next_application_id
from ..utils.asyncio.pool import Pool
from ..utils.packet_filter_validation import validate_bpf_syntax
from ..utils.asyncio import locking
from ..utils.asyncio import aiozipstream
from ..utils.asyncio import wait_run_in_executor
@@ -211,6 +212,7 @@ class Project:
self._allocated_node_names = set()
self._nodes = {}
self._links = {}
self._marker_definitions = {} # name → {bpf, tag, color, highlight_duration}
self._drawings = {}
self._snapshots = {}
self._computes = []
@@ -765,6 +767,31 @@ class Project:
"Dropping invalid filters on link %s: %s",
link_data.get("link_id"), e
)
# Restore traffic-insight markers directly into link state (mirrors how
# filters are restored via update_filters). The capture_node_id persisted
# last time is reused for NIO routing; no side resolution is possible here
# because the link's nodes are added later. The marker is applied to
# uBridge by _ubridge_apply_markers when create() runs. Invalid BPF is
# dropped (like invalid filters).
for name, marker in (link_data.get("markers") or {}).items():
bpf = marker.get("bpf")
if not bpf:
log.warning("Dropping marker %s on link %s: missing bpf", name, link_data.get("link_id"))
continue
result = validate_bpf_syntax(bpf)
if not result.get("valid"):
log.warning(
"Dropping marker %s on link %s: invalid BPF (%s)",
name, link_data.get("link_id"), result.get("error")
)
continue
link._markers[name] = {
"bpf": bpf,
"tag": marker.get("tag"),
"enabled": marker.get("enabled", True),
"color": marker.get("color"),
"capture_node_id": marker.get("capture_node_id"),
}
if "link_style" in link_data:
await link.update_link_style(link_data["link_style"])
if "show_filters_icon" in link_data:
@@ -872,6 +899,146 @@ class Project:
return self._get_closed_data("links", "link_id")
return self._links
@property
def markers(self):
"""
Project-level read-only aggregation of all markers across every link.
Each entry is keyed ``"{link_id}/{marker_name}"`` so the flat dict is
globally unique within the project. The value is a clone of the link's
per-marker dict plus ``link_id`` and ``node_id`` (the capture-side node)
for convenience — the frontend can filter/group by link or node without
extra round-trips.
:returns: dict[str, dict] — keyed by "{link_id}/{marker_name}"
"""
result = {}
for link_id, link in self._links.items():
for name, info in link.markers.items():
key = f"{link_id}/{name}"
result[key] = {
**info,
"link_id": link_id,
"node_id": info.get("capture_node_id"),
}
return result
@property
def marker_definitions(self):
"""
:returns: dict of project-level marker definitions (name → {bpf, tag, color, highlight_duration})
"""
return self._marker_definitions
async def create_marker_definition(self, name, bpf, tag=None, color=None, highlight_duration=None):
"""
Create a project-level marker definition and fan out to every existing
link that has a capable node. Links without a capable node are silently
skipped.
"""
if name in self._marker_definitions:
raise ControllerError(
f"Marker definition '{name}' already exists in this project"
)
self._marker_definitions[name] = {"bpf": bpf, "tag": tag, "color": color, "highlight_duration": highlight_duration}
await self._apply_def_to_all_links(name)
self.dump()
self.emit_notification("project.updated", self.asdict())
async def update_marker_definition(self, name, bpf=None, tag=None, color=None, highlight_duration=None):
"""
Update a marker definition and sync every inherited copy on every link.
"""
if name not in self._marker_definitions:
raise ControllerNotFoundError(
f"Marker definition '{name}' not found in this project"
)
d = self._marker_definitions[name]
if bpf is not None:
d["bpf"] = bpf
if tag is not None:
d["tag"] = tag
if color is not None:
d["color"] = color
if highlight_duration is not None:
d["highlight_duration"] = highlight_duration
# Sync: update every inherited copy across all links.
for link in list(self._links.values()):
marker_name = f"global-{name}"
if marker_name in link.markers and link.markers[marker_name].get("inherited_from") == name:
await link.update_marker(
marker_name, bpf=d["bpf"], tag=d.get("tag"), color=d.get("color"),
highlight_duration=d.get("highlight_duration"), inherited=True
)
self.dump()
self.emit_notification("project.updated", self.asdict())
async def delete_marker_definition(self, name):
"""
Delete a marker definition and remove every inherited copy from every link.
"""
if name not in self._marker_definitions:
raise ControllerNotFoundError(
f"Marker definition '{name}' not found in this project"
)
del self._marker_definitions[name]
for link in list(self._links.values()):
marker_name = f"global-{name}"
if marker_name in link.markers and link.markers[marker_name].get("inherited_from") == name:
try:
await link.stop_marker(marker_name, inherited=True)
except ControllerError:
# A missing compute or broken link shouldn't block the delete.
log.warning(
"Failed to remove inherited marker %s from link %s",
marker_name, link.id
)
self.dump()
self.emit_notification("project.updated", self.asdict())
async def _apply_def_to_all_links(self, def_name):
"""
Fan out a single marker definition to every existing link in the project.
Links that have no capable node (``_MARKER_CAPABLE_TYPES``) are silently
skipped — the marker can only live on a uBridge bridge.
"""
d = self._marker_definitions[def_name]
for link in list(self._links.values()):
try:
await link.inherit_marker(def_name, d)
except ControllerError as e:
# Per-link failures (e.g. no capable node) shouldn't block the
# definition from serving the rest.
log.warning(
"Marker definition '%s' could not be applied to link %s: %s",
def_name, link.id, e
)
async def apply_defs_to_new_link(self, link):
"""
Apply every active marker definition to a newly created link so it
inherits project-level rules automatically.
"""
for def_name, d in self._marker_definitions.items():
try:
await link.inherit_marker(def_name, d)
except ControllerError as e:
log.warning(
"Marker definition '%s' could not be applied to new link %s: %s",
def_name, link.id, e
)
@property
def snapshots(self):
"""
@@ -1262,6 +1429,12 @@ class Project:
if val is not None:
setattr(self, key, val)
# marker_definitions is loaded separately (it is not a __init__ kwarg
# nor a simple attribute — it backs a read-only property).
defs = project_data.get("marker_definitions")
if isinstance(defs, dict):
self._marker_definitions = defs
topology = project_data["topology"]
for compute in topology.get("computes", []):
compute_id = compute.get("compute_id")
@@ -1328,6 +1501,10 @@ class Project:
for drawing_data in topology.get("drawings", []):
await self.add_drawing(dump=False, **drawing_data)
# Note: project-level marker definitions are applied to each link
# inside UDPLink.create() (the inheritance hook), so they are
# already present once links are loaded — no separate fan-out here.
self.dump()
# We catch all error to be able to roll back the .gns3 to the previous state
except Exception as e:
@@ -1684,6 +1861,7 @@ class Project:
"links": len(self._links),
"drawings": len(self._drawings),
"snapshots": len(self._snapshots),
"markers": sum(len(link.markers) for link in self._links.values()),
}
def asdict(self):
@@ -1708,6 +1886,7 @@ class Project:
"supplier": self._supplier,
"variables": self._variables,
"created_by": self._created_by,
"marker_definitions": self._marker_definitions,
}
def __repr__(self):
+1
View File
@@ -88,6 +88,7 @@ def project_to_topology(project):
"variables": project.variables,
"supplier": project.supplier,
"created_by": project.created_by,
"marker_definitions": project.marker_definitions,
"topology": {"nodes": [], "links": [], "computes": [], "drawings": []},
"type": "topology",
"revision": GNS3_FILE_FORMAT_REVISION,
+191 -1
View File
@@ -19,6 +19,15 @@
from .controller_error import ControllerError, ControllerNotFoundError
from .link import Link
from .node_types import BUILTIN_NODE_TYPES
from gns3server.utils.packet_filter_validation import validate_bpf_syntax, FilterValidationError
# Node types without a uBridge bridge — a marker filter has nothing to attach to.
# Node types that can host a marker (have a uBridge bridge to attach the
# `mark` filter to). Mirrors _get_filter_node in link.py, minus "nat"
# (which has no uBridge).
_MARKER_CAPABLE_TYPES = frozenset({
"vpcs", "qemu", "docker", "iou", "dynamips", "cloud",
})
class UDPLink(Link):
@@ -37,7 +46,7 @@ class UDPLink(Link):
def _get_node_filters(self, node1, node2):
"""
Determine which node gets the active filters applied.
:returns: Tuple of (node1_filters, node2_filters)
"""
filter_node = self._get_filter_node()
@@ -46,6 +55,26 @@ class UDPLink(Link):
self.get_active_filters() if filter_node == node2 else {},
)
def _markers_for_node(self, node):
"""
Marker specs (name -> {bpf, tag, link_id}) for the markers whose capture
side is ``node`` and that are enabled. Routed by capture_node_id so a
marker only rides the NIO of the node whose uBridge will host it.
"""
return {
name: {"bpf": m["bpf"], "tag": m.get("tag"), "link_id": self._id}
for name, m in self._markers.items()
if m.get("enabled", True) and m.get("capture_node_id") == node.id
}
def _get_node_markers(self, node1, node2):
"""
Determine which node gets which markers applied.
:returns: Tuple of (node1_markers, node2_markers)
"""
return self._markers_for_node(node1), self._markers_for_node(node2)
async def create(self):
"""
Create the link on the nodes
@@ -80,6 +109,7 @@ class UDPLink(Link):
self._node2_port = response.json["udp_port"]
node1_filters, node2_filters = self._get_node_filters(node1, node2)
node1_markers, node2_markers = self._get_node_markers(node1, node2)
# Create the tunnel on both side
self._link_data.append(
@@ -89,6 +119,7 @@ class UDPLink(Link):
"rport": self._node2_port,
"type": "nio_udp",
"filters": node1_filters,
"markers": node1_markers,
"suspend": self._suspended,
}
)
@@ -101,6 +132,7 @@ class UDPLink(Link):
"rport": self._node1_port,
"type": "nio_udp",
"filters": node2_filters,
"markers": node2_markers,
"suspend": self._suspended,
}
)
@@ -113,6 +145,9 @@ class UDPLink(Link):
await node1.delete(f"/adapters/{adapter_number1}/ports/{port_number1}/nio", timeout=120)
raise e
self._created = True
# New links automatically inherit every active project-level marker
# definition so the user doesn't have to reconfigure.
await self._project.apply_defs_to_new_link(self)
async def update(self):
"""
@@ -125,10 +160,12 @@ class UDPLink(Link):
node2 = self._nodes[1]["node"]
node1_filters, node2_filters = self._get_node_filters(node1, node2)
node1_markers, node2_markers = self._get_node_markers(node1, node2)
adapter_number1 = self._nodes[0]["adapter_number"]
port_number1 = self._nodes[0]["port_number"]
self._link_data[0]["filters"] = node1_filters
self._link_data[0]["markers"] = node1_markers
self._link_data[0]["suspend"] = self._suspended
if node1.node_type not in ("ethernet_switch", "ethernet_hub"):
await node1.put(
@@ -138,6 +175,7 @@ class UDPLink(Link):
adapter_number2 = self._nodes[1]["adapter_number"]
port_number2 = self._nodes[1]["port_number"]
self._link_data[1]["filters"] = node2_filters
self._link_data[1]["markers"] = node2_markers
self._link_data[1]["suspend"] = self._suspended
if node2.node_type not in ("ethernet_switch", "ethernet_hub"):
await node2.put(
@@ -245,9 +283,161 @@ class UDPLink(Link):
raise ControllerError("Cannot capture because there is no running device on this link")
def _choose_marker_side(self):
"""
Pick the node that will host the marker, mirroring ``_get_filter_node``
in link.py. Only types with a uBridge bridge (``_MARKER_CAPABLE_TYPES``)
are eligible. A running node is preferred, but a stopped one is
accepted — like packet filters, the marker is stored on the NIO and
applied when the node starts.
"""
# Prefer started.
for node in self._nodes:
if (
node["node"].node_type in _MARKER_CAPABLE_TYPES
and node["node"].status == "started"
):
return node
# Accept stopped but capable (marker rides NIO, applied at start).
for node in self._nodes:
if node["node"].node_type in _MARKER_CAPABLE_TYPES:
return node
raise ControllerError(
"Cannot add marker because no device on this link supports "
"traffic insight"
)
async def node_updated(self, node):
"""
Called when a node member of the link is updated
"""
if self._capture_node and node == self._capture_node["node"] and node.status != "started":
await self.stop_capture()
# Marker clean-up is *not* done on node stop — markers are a persistent
# link-scoped feature that recovers via NIO on restart (see
# _ubridge_apply_markers in add_ubridge_udp_connection). The user
# explicitly deletes a marker via the REST API, and a marker is torn
# down automatically only when its link is deleted.
async def start_marker(self, name, bpf, tag=None, color=None, highlight_duration=None, inherited_from=None):
"""
Attach a traffic-insight marker to this link.
State-only model (mirrors ``update_filters``): record the marker in
``_markers`` (with its capture-side node id for NIO routing), then push
via ``self.update()`` so it rides the NIO and is applied by
``_ubridge_apply_markers``. No dedicated uBridge round-trip — exactly
how packet filters are applied.
:param name: stable filter name — echoed in MARK signals + pcap identity
:param bpf: libpcap BPF expression
:param tag: optional correlation id
:param color: optional hex color for the Web UI (e.g. '#ff5722'); stored
with the link and persisted in the topology, never sent to uBridge
:param highlight_duration: optional UI-only hint (milliseconds) for how
long a match keeps the marker highlighted; stored, never sent to uBridge
:param inherited_from: def name when this marker is a project-level
inheritance copy; set automatically, never exposed to REST callers
"""
if name in self._markers:
raise ControllerError(f"Marker '{name}' already exists on link {self._id}")
result = validate_bpf_syntax(bpf)
if not result.get("valid"):
raise ControllerError(f"Invalid BPF expression: {result.get('error', 'unknown error')}")
marker_side = self._choose_marker_side()
marker_entry = {
"bpf": bpf,
"tag": tag,
"enabled": True,
"color": color,
"highlight_duration": highlight_duration,
"capture_node_id": marker_side["node"].id,
}
if inherited_from:
marker_entry["inherited_from"] = inherited_from
self._markers[name] = marker_entry
if self._created:
await self.update()
self._project.emit_notification("link.updated", self.asdict())
self._project.dump()
async def stop_marker(self, name, inherited=False):
"""
Remove a traffic-insight marker from this link.
Drop it from ``_markers`` and push via ``self.update()``: the NIO
reset+reapply in ``_ubridge_apply_filters``/``_ubridge_apply_markers``
drops it from uBridge. Mirrors how deleting a packet filter works.
:param name: filter name to remove
:param inherited: set by project-level def-delete to bypass the
inheritance guard (the project layer is the legitimate remover)
"""
if name not in self._markers:
raise ControllerNotFoundError(f"Marker '{name}' not found on link {self._id}")
if self._markers[name].get("inherited_from") and not inherited:
raise ControllerError(
f"Marker '{name}' is inherited from the project-level "
f"definition '{self._markers[name]['inherited_from']}'. "
"Delete or update it via the marker-definitions API instead."
)
del self._markers[name]
if self._created:
await self.update()
self._project.emit_notification("link.updated", self.asdict())
self._project.dump()
async def update_marker(self, name, bpf=None, tag=None, enabled=None, color=None, highlight_duration=None, inherited=False):
"""
Update an existing marker's BPF/tag/enabled/color. Any change pushes via
``self.update()``; uBridge picks up the new params on the next NIO
reset+reapply (same as packet filters).
:param name: filter name to update
:param bpf: new BPF expression (None = keep existing)
:param tag: new tag id (None = keep existing)
:param enabled: toggle (None = keep existing)
:param color: new hex color (None = keep existing)
:param highlight_duration: new UI highlight duration in ms (None = keep existing)
:param inherited: set by project-level sync to bypass the inheritance
guard (the project layer is the legitimate editor)
"""
marker_info = self._markers.get(name)
if not marker_info:
raise ControllerNotFoundError(f"Marker '{name}' not found on link {self._id}")
if marker_info.get("inherited_from") and not inherited:
raise ControllerError(
f"Marker '{name}' is inherited from the project-level "
f"definition '{marker_info['inherited_from']}'. "
"Update it via the marker-definitions API instead."
)
if bpf is not None and bpf != marker_info["bpf"]:
result = validate_bpf_syntax(bpf)
if not result.get("valid"):
raise ControllerError(f"Invalid BPF expression: {result.get('error', 'unknown error')}")
marker_info["bpf"] = bpf
if tag is not None:
marker_info["tag"] = tag
if enabled is not None:
marker_info["enabled"] = enabled
if color is not None:
marker_info["color"] = color
if highlight_duration is not None:
marker_info["highlight_duration"] = highlight_duration
if self._created:
await self.update()
self._project.emit_notification("link.updated", self.asdict())
self._project.dump()
+10
View File
@@ -24,6 +24,7 @@ from gns3server.controller import Controller
from gns3server.config import Config
from gns3server.compute import MODULES
from gns3server.compute.port_manager import PortManager
from gns3server.compute.marker.marker_manager import MarkerManager
from gns3server.utils.http_client import HTTPClient
from gns3server.db.tasks import connect_to_db, get_computes, disconnect_from_db, discover_images_on_filesystem
@@ -84,6 +85,14 @@ async def startup(app: FastAPI) -> None:
m = module.instance()
m.port_manager = PortManager.instance()
# Start the marker (traffic-insight) UDP sink. One listener per compute
# process receives ubridge MARK signals; ubridges are told its host/port at
# startup (see BaseNode._start_ubridge).
server_settings = Config.instance().settings.Server
await MarkerManager.instance().start(
host=server_settings.marker_listen_host, port=server_settings.marker_listen_port
)
# Mark MCP server as ready to accept connections (if MCP is available)
from gns3server.agent import MCP_AVAILABLE
@@ -101,6 +110,7 @@ async def shutdown(app: FastAPI) -> None:
if auto_discover_images_task_handle is not None and not auto_discover_images_task_handle.cancelled():
auto_discover_images_task_handle.cancel()
await HTTPClient.close_session()
await MarkerManager.instance().stop()
await Controller.instance().stop()
for module in MODULES:
+1 -1
View File
@@ -20,7 +20,7 @@ from .common import ErrorMessage
from .version import Version
# Controller schemas
from .controller.links import LinkCreate, LinkUpdate, Link, UDPPortInfo, EthernetPortInfo, LinkCapture
from .controller.links import LinkCreate, LinkUpdate, Link, UDPPortInfo, EthernetPortInfo, LinkCapture, MarkerCreate, MarkerDefinitionCreate
from .controller.computes import ComputeCreate, ComputeUpdate, ComputeVirtualBoxVM, ComputeVMwareVM, ComputeDockerImage, AutoIdlePC, Compute
from .controller.templates import TemplateCreate, TemplateUpdate, TemplateUsage, Template
from .controller.images import Image, ImageType
+1
View File
@@ -36,6 +36,7 @@ class UDPNIO(BaseModel):
rport: int = Field(..., gt=0, le=65535, description="Remote port")
suspend: Optional[bool] = Field(None, description="Suspend the NIO")
filters: Optional[dict] = Field(None, description="Packet filters")
markers: Optional[dict] = Field(None, description="Traffic-insight markers")
class EthernetNIOType(str, Enum):
+6
View File
@@ -153,6 +153,12 @@ class ServerSettings(BaseModel):
udp_start_port_range: int = Field(10000, gt=0, le=65535)
udp_end_port_range: int = Field(30000, gt=0, le=65535)
ubridge_path: str = "ubridge"
# Marker (traffic-insight) UDP sink: one listener per compute process that
# receives ubridge MARK signals from every ubridge on this host. The host
# defaults to loopback because ubridge runs on the same host as the compute.
# port=0 lets the OS choose a free port (read back and handed to ubridge).
marker_listen_host: str = "127.0.0.1"
marker_listen_port: int = Field(3070, ge=0, le=65535)
compute_username: str = "gns3"
compute_password: SecretStr = SecretStr("")
allowed_interfaces: List[str] = Field(default_factory=list)
+74
View File
@@ -62,6 +62,10 @@ class LinkBase(BaseModel):
suspend: Optional[bool] = None
link_style: Optional[LinkStyle] = None
filters: Optional[dict] = None
markers: Optional[dict] = Field(
None,
description="Traffic-insight markers on this link: name → {bpf, tag, enabled}"
)
show_filters_icon: Optional[bool] = Field(
True,
description="Show filters icon in Web UI"
@@ -135,3 +139,73 @@ class LinkCapture(BaseModel):
data_link_type: str = "DLT_EN10MB"
capture_file_name: Optional[str] = None
wireshark: bool = False
class MarkerCreate(BaseModel):
"""
Body for attaching a traffic-insight marker to a link.
``name`` is optional at the controller REST layer (auto-generated when
absent) but always set when the controller forwards to the compute.
"""
name: Optional[str] = Field(
None,
pattern=r"^[A-Za-z0-9][A-Za-z0-9_.-]*$",
max_length=128,
description='Unique marker name on the link. Auto-generated when absent.',
)
bpf: str
tag: Optional[int] = None
link_id: Optional[str] = None
color: Optional[str] = Field(
None,
description="User-chosen hex color for this marker in the Web UI, e.g. '#ff5722'",
)
highlight_duration: Optional[int] = Field(
None,
ge=1,
description=(
"How long (milliseconds) the Web UI keeps this marker highlighted "
"after a match. Omitted = use the UI default. Pure render hint — "
"stored on the link, never sent to uBridge."
),
)
enabled: Optional[bool] = Field(
None,
description="Whether the marker is active. Defaults to true on creation.",
)
class MarkerDefinitionCreate(BaseModel):
"""
Body for creating / updating a project-level marker definition.
The definition is a template — when applied to a link the marker name is
prefixed with ``global-`` (e.g. ``arp`` → ``global-arp``) so it can never
collide with a per-link private marker.
"""
name: Optional[str] = Field(
None,
pattern=r"^[A-Za-z0-9][A-Za-z0-9_.-]*$",
max_length=128,
description="Unique definition name. Auto-generated when absent.",
)
bpf: str
tag: Optional[int] = None
color: Optional[str] = Field(
None,
description="User-chosen hex color for the marker in the Web UI, e.g. '#ff5722'",
)
highlight_duration: Optional[int] = Field(
None,
ge=1,
description=(
"How long (milliseconds) the Web UI keeps this marker highlighted "
"after a match. Omitted = use the UI default. Pure render hint — "
"stored with the definition, never sent to uBridge."
),
)