From d2e9823e6c9471ea061ae504b1ae25f36e36b4e8 Mon Sep 17 00:00:00 2001 From: YueGuobin Date: Sun, 12 Jul 2026 01:05:55 +0800 Subject: [PATCH 01/31] feat: implement traffic insight marker backend (ubridge mark filter) Add compute-side marker subsystem that receives ubridge UDP MARK signals and forwards them as project-scoped notifications to the web UI for real-time traffic coloring. Matched packets are always saved to per-link pcaps for future replay. Key components: - gns3server/compute/marker/: MarkerManager (singleton, UDP listener + O(1) registry keyed by (node_id, filter_name)) + MarkerListener (DatagramProtocol parsing MARK lines per ubridge integration contract) - gns3server/compute/base_node.py: marker sink/node config at ubridge startup; shared _ubridge_add_marker_filter / _ubridge_delete_marker_filter - Per-node start_marker/stop_marker: VPCS (VPCS-{id}), QEMU (QEMU-{id}-{adapter}), Docker (bridge{adapter}) - Compute REST /markers/start + /markers/stop (vpcs/qemu/docker route files) - Controller Link._markers state + UDPLink.start_marker/stop_marker/ update_marker (mirror capture pattern: BPF validation, _choose_capture_side, node.post forwarding, topology persistence) - Controller REST GET/POST/DELETE/PUT /v3/projects/{p}/links/{l}/markers - Config: marker_listen_host / marker_listen_port in ServerSettings - Signal routing: creation-time registry O(1) lookup, no node-table scan; project-scoped WS stream (not global); event payload always carries project_id for frontend scoping Tests: 14 unit tests (registry, listener parsing, UDP round-trip); 562 existing tests pass with zero regressions. --- gns3server/api/routes/compute/docker_nodes.py | 47 ++++ gns3server/api/routes/compute/qemu_nodes.py | 47 ++++ gns3server/api/routes/compute/vpcs_nodes.py | 49 ++++ gns3server/api/routes/controller/links.py | 79 +++++++ gns3server/compute/base_node.py | 70 ++++++ gns3server/compute/docker/docker_vm.py | 38 +++ gns3server/compute/marker/__init__.py | 24 ++ gns3server/compute/marker/marker_listener.py | 102 ++++++++ gns3server/compute/marker/marker_manager.py | 163 +++++++++++++ gns3server/compute/project.py | 16 ++ gns3server/compute/qemu/qemu_vm.py | 38 +++ gns3server/compute/vpcs/vpcs_vm.py | 36 +++ gns3server/controller/link.py | 31 +++ gns3server/controller/udp_link.py | 132 +++++++++++ gns3server/core/tasks.py | 10 + gns3server/schemas/__init__.py | 2 +- gns3server/schemas/config.py | 6 + gns3server/schemas/controller/links.py | 26 +++ tests/compute/marker/__init__.py | 0 tests/compute/marker/test_marker_manager.py | 218 ++++++++++++++++++ tests/controller/test_link.py | 2 + 21 files changed, 1135 insertions(+), 1 deletion(-) create mode 100644 gns3server/compute/marker/__init__.py create mode 100644 gns3server/compute/marker/marker_listener.py create mode 100644 gns3server/compute/marker/marker_manager.py create mode 100644 tests/compute/marker/__init__.py create mode 100644 tests/compute/marker/test_marker_manager.py diff --git a/gns3server/api/routes/compute/docker_nodes.py b/gns3server/api/routes/compute/docker_nodes.py index 3e30628a2..f8e8b8bb6 100644 --- a/gns3server/api/routes/compute/docker_nodes.py +++ b/gns3server/api/routes/compute/docker_nodes.py @@ -29,6 +29,7 @@ from typing import Union from gns3server import schemas from gns3server.compute.docker import Docker from gns3server.compute.docker.docker_vm import DockerVM +from gns3server.compute.marker.marker_manager import MarkerManager from .dependencies.authentication import compute_authentication, ws_compute_authentication @@ -353,6 +354,52 @@ async def stop_docker_node_capture( await node.stop_capture(adapter_number) +@router.post( + "/{node_id}/adapters/{adapter_number}/ports/{port_number}/markers/start", + dependencies=[Depends(compute_authentication)] +) +async def start_docker_node_marker( + *, + project_id: UUID, + adapter_number: int, + port_number: int, + marker_data: schemas.MarkerCreate, + node: DockerVM = Depends(dep_node) +) -> dict: + """ + Attach a traffic-insight ``mark`` filter to the Docker node's uBridge bridge. + """ + + pcap_path = os.path.join( + node.project.markers_working_directory(), + f"{node.id}_{marker_data.link_id}_{marker_data.name}.pcap" + ) + await node.start_marker(adapter_number, marker_data.name, marker_data.bpf, pcap_path, marker_data.tag) + MarkerManager.instance().register( + str(project_id), node.id, marker_data.name, marker_data.link_id, marker_data.tag + ) + return {"pcap_file_path": str(pcap_path)} + + +@router.post( + "/{node_id}/adapters/{adapter_number}/ports/{port_number}/markers/stop", + status_code=status.HTTP_204_NO_CONTENT, + dependencies=[Depends(compute_authentication)] +) +async def stop_docker_node_marker( + adapter_number: int, + port_number: int, + marker_data: schemas.MarkerDelete, + node: DockerVM = Depends(dep_node) +) -> None: + """ + Remove a traffic-insight ``mark`` filter from the Docker node's uBridge bridge. + """ + + await node.stop_marker(adapter_number, marker_data.name) + MarkerManager.instance().unregister(node.id, marker_data.name) + + @router.get( "/{node_id}/adapters/{adapter_number}/ports/{port_number}/capture/stream", dependencies=[Depends(compute_authentication)] diff --git a/gns3server/api/routes/compute/qemu_nodes.py b/gns3server/api/routes/compute/qemu_nodes.py index 623ae8dae..6c8456eeb 100644 --- a/gns3server/api/routes/compute/qemu_nodes.py +++ b/gns3server/api/routes/compute/qemu_nodes.py @@ -30,6 +30,7 @@ from gns3server import schemas from gns3server.compute import qemu from gns3server.compute.qemu import Qemu from gns3server.compute.qemu.qemu_vm import QemuVM +from gns3server.compute.marker.marker_manager import MarkerManager from .dependencies.authentication import compute_authentication, ws_compute_authentication @@ -382,6 +383,52 @@ async def stop_qemu_node_capture( await node.stop_capture(adapter_number) +@router.post( + "/{node_id}/adapters/{adapter_number}/ports/{port_number}/markers/start", + dependencies=[Depends(compute_authentication)] +) +async def start_qemu_node_marker( + *, + project_id: UUID, + adapter_number: int, + marker_data: schemas.MarkerCreate, + port_number: int = Path(..., ge=0, le=0), + node: QemuVM = Depends(dep_node) +) -> dict: + """ + Attach a traffic-insight ``mark`` filter to the QEMU node's uBridge bridge. + """ + + pcap_path = os.path.join( + node.project.markers_working_directory(), + f"{node.id}_{marker_data.link_id}_{marker_data.name}.pcap" + ) + await node.start_marker(adapter_number, marker_data.name, marker_data.bpf, pcap_path, marker_data.tag) + MarkerManager.instance().register( + str(project_id), node.id, marker_data.name, marker_data.link_id, marker_data.tag + ) + return {"pcap_file_path": str(pcap_path)} + + +@router.post( + "/{node_id}/adapters/{adapter_number}/ports/{port_number}/markers/stop", + status_code=status.HTTP_204_NO_CONTENT, + dependencies=[Depends(compute_authentication)] +) +async def stop_qemu_node_marker( + adapter_number: int, + marker_data: schemas.MarkerDelete, + port_number: int = Path(..., ge=0, le=0), + node: QemuVM = Depends(dep_node) +) -> None: + """ + Remove a traffic-insight ``mark`` filter from the QEMU node's uBridge bridge. + """ + + await node.stop_marker(adapter_number, marker_data.name) + MarkerManager.instance().unregister(node.id, marker_data.name) + + @router.get( "/{node_id}/adapters/{adapter_number}/ports/{port_number}/capture/stream", dependencies=[Depends(compute_authentication)] diff --git a/gns3server/api/routes/compute/vpcs_nodes.py b/gns3server/api/routes/compute/vpcs_nodes.py index c51f0254d..60360f579 100644 --- a/gns3server/api/routes/compute/vpcs_nodes.py +++ b/gns3server/api/routes/compute/vpcs_nodes.py @@ -29,6 +29,7 @@ from uuid import UUID from gns3server import schemas from gns3server.compute.vpcs import VPCS from gns3server.compute.vpcs.vpcs_vm import VPCSVM +from gns3server.compute.marker.marker_manager import MarkerManager from .dependencies.authentication import compute_authentication, ws_compute_authentication @@ -303,6 +304,54 @@ async def stop_vpcs_node_capture( await node.stop_capture(port_number) +@router.post( + "/{node_id}/adapters/{adapter_number}/ports/{port_number}/markers/start", + dependencies=[Depends(compute_authentication)] +) +async def start_vpcs_node_marker( + *, + project_id: UUID, + port_number: int, + marker_data: schemas.MarkerCreate, + adapter_number: int = Path(..., ge=0, le=0), + node: VPCSVM = Depends(dep_node) +) -> dict: + """ + Attach a traffic-insight ``mark`` filter to the VPCS node's uBridge bridge. + On BPF match uBridge emits a MARK signal and appends the packet to the pcap. + """ + + pcap_path = os.path.join( + node.project.markers_working_directory(), + f"{node.id}_{marker_data.link_id}_{marker_data.name}.pcap" + ) + await node.start_marker(port_number, marker_data.name, marker_data.bpf, pcap_path, marker_data.tag) + MarkerManager.instance().register( + str(project_id), node.id, marker_data.name, marker_data.link_id, marker_data.tag + ) + return {"pcap_file_path": pcap_path} + + +@router.post( + "/{node_id}/adapters/{adapter_number}/ports/{port_number}/markers/stop", + status_code=status.HTTP_204_NO_CONTENT, + dependencies=[Depends(compute_authentication)] +) +async def stop_vpcs_node_marker( + *, + port_number: int, + marker_data: schemas.MarkerDelete, + adapter_number: int = Path(..., ge=0, le=0), + node: VPCSVM = Depends(dep_node) +) -> None: + """ + Remove a traffic-insight ``mark`` filter from the VPCS node's uBridge bridge. + """ + + await node.stop_marker(port_number, marker_data.name) + MarkerManager.instance().unregister(node.id, marker_data.name) + + @router.get( "/{node_id}/adapters/{adapter_number}/ports/{port_number}/capture/stream", dependencies=[Depends(compute_authentication)] diff --git a/gns3server/api/routes/controller/links.py b/gns3server/api/routes/controller/links.py index ce9caf450..bb723927a 100644 --- a/gns3server/api/routes/controller/links.py +++ b/gns3server/api/routes/controller/links.py @@ -424,6 +424,85 @@ 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 + """ + + await link.start_marker( + name=marker_data.name or f"marker-{link.id[:8]}", + bpf=marker_data.bpf, + tag=marker_data.tag, + ) + return link.markers.get(marker_data.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, + ) + return link.markers.get(marker_name, {}) + + @router.get( "/{link_id}/iface", response_model=Union[schemas.UDPPortInfo, schemas.EthernetPortInfo], diff --git a/gns3server/compute/base_node.py b/gns3server/compute/base_node.py index 8e7681d9e..fc2701ab0 100644 --- a/gns3server/compute/base_node.py +++ b/gns3server/compute/base_node.py @@ -935,9 +935,38 @@ 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 + try: + await self._ubridge_send(f"marker sink {manager.host} {manager.port}") + await self._ubridge_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. @@ -1042,6 +1071,47 @@ class BaseNode: ) i += 1 + async def _ubridge_add_marker_filter(self, bridge_name, name, bpf, pcap_path, tag=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 [tag ] [pcap ] — tag/pcap keyword pairs, any order. + 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}" + 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_delete_marker_filter(self, bridge_name, name): + """ + Remove a `mark` filter from a uBridge bridge. + + uBridge closes and flushes the filter's pcap on delete; the file itself + persists on disk for later replay. + + :param bridge_name: uBridge bridge the filter is attached to + :param name: filter name previously passed to _ubridge_add_marker_filter + """ + + await self._ubridge_send(f"bridge delete_packet_filter {bridge_name} {name}") + async def _add_ubridge_ethernet_connection(self, bridge_name, ethernet_interface, block_host_traffic=False): """ Creates a connection with an Ethernet interface in uBridge. diff --git a/gns3server/compute/docker/docker_vm.py b/gns3server/compute/docker/docker_vm.py index 8c139cdfd..5b787a39f 100644 --- a/gns3server/compute/docker/docker_vm.py +++ b/gns3server/compute/docker/docker_vm.py @@ -1440,6 +1440,44 @@ class DockerVM(BaseNode): ) ) + async def start_marker(self, adapter_number, name, bpf, pcap_path, tag=None): + """ + Attach a traffic-insight ``mark`` filter to this adapter's uBridge bridge. + On BPF match uBridge emits a MARK signal and appends the packet to the pcap. + + :param adapter_number: adapter number + :param name: stable filter name — pcap identity and echoed in MARK 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 + """ + + if self.status == "started" and self.ubridge: + adapter = f"bridge{adapter_number}" + await self._ubridge_add_marker_filter(adapter, name, bpf, pcap_path, tag) + log.info( + "Docker VM '{name}' [{id}]: starting marker '{marker}' on adapter {adapter_number}".format( + name=self.name, id=self.id, marker=name, adapter_number=adapter_number + ) + ) + + async def stop_marker(self, adapter_number, name): + """ + Remove a traffic-insight ``mark`` filter from this adapter's uBridge bridge. + + :param adapter_number: adapter number + :param name: filter name previously passed to start_marker + """ + + if self.status == "started" and self.ubridge: + adapter = f"bridge{adapter_number}" + await self._ubridge_delete_marker_filter(adapter, name) + log.info( + "Docker VM '{name}' [{id}]: stopping marker '{marker}' on adapter {adapter_number}".format( + name=self.name, id=self.id, marker=name, adapter_number=adapter_number + ) + ) + async def _get_log(self): """ Returns the log from the container diff --git a/gns3server/compute/marker/__init__.py b/gns3server/compute/marker/__init__.py new file mode 100644 index 000000000..8fdb2b775 --- /dev/null +++ b/gns3server/compute/marker/__init__.py @@ -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 . +# +# +# 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=``. diff --git a/gns3server/compute/marker/marker_listener.py b/gns3server/compute/marker/marker_listener.py new file mode 100644 index 000000000..d8bfa3303 --- /dev/null +++ b/gns3server/compute/marker/marker_listener.py @@ -0,0 +1,102 @@ +#!/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 . + +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 node= filter= tag= len=\\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] == "" + 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). + 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 + + event = { + "project_id": project_id, + "node_id": node_id, + "link_id": 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) diff --git a/gns3server/compute/marker/marker_manager.py b/gns3server/compute/marker/marker_manager.py new file mode 100644 index 000000000..7a4168762 --- /dev/null +++ b/gns3server/compute/marker/marker_manager.py @@ -0,0 +1,163 @@ +#!/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 . + +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=`` (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) + self._transport, _ = await loop.create_datagram_endpoint( + lambda: self._listener, local_addr=(host, port) + ) + 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 diff --git a/gns3server/compute/project.py b/gns3server/compute/project.py index eebb65ca1..ae3b5c8d1 100644 --- a/gns3server/compute/project.py +++ b/gns3server/compute/project.py @@ -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. diff --git a/gns3server/compute/qemu/qemu_vm.py b/gns3server/compute/qemu/qemu_vm.py index f91177111..b659d9cb4 100644 --- a/gns3server/compute/qemu/qemu_vm.py +++ b/gns3server/compute/qemu/qemu_vm.py @@ -1697,6 +1697,44 @@ class QemuVM(BaseNode): ) ) + async def start_marker(self, adapter_number, name, bpf, pcap_path, tag=None): + """ + Attach a traffic-insight ``mark`` filter to this adapter's uBridge bridge. + On BPF match uBridge emits a MARK signal and appends the packet to the pcap. + + :param adapter_number: adapter number + :param name: stable filter name — pcap identity and echoed in MARK 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 + """ + + if self.ubridge: + await self._ubridge_add_marker_filter( + f"QEMU-{self._id}-{adapter_number}", name, bpf, pcap_path, tag + ) + log.info( + "QEMU VM '{name}' [{id}]: starting marker '{marker}' on adapter {adapter_number}".format( + name=self.name, id=self.id, marker=name, adapter_number=adapter_number + ) + ) + + async def stop_marker(self, adapter_number, name): + """ + Remove a traffic-insight ``mark`` filter from this adapter's uBridge bridge. + + :param adapter_number: adapter number + :param name: filter name previously passed to start_marker + """ + + if self.ubridge: + await self._ubridge_delete_marker_filter(f"QEMU-{self._id}-{adapter_number}", name) + log.info( + "QEMU VM '{name}' [{id}]: stopping marker '{marker}' on adapter {adapter_number}".format( + name=self.name, id=self.id, marker=name, adapter_number=adapter_number + ) + ) + async def create_disk_image(self, disk_name, options): """ Create a Qemu disk diff --git a/gns3server/compute/vpcs/vpcs_vm.py b/gns3server/compute/vpcs/vpcs_vm.py index b42c4095a..0d3353a9a 100644 --- a/gns3server/compute/vpcs/vpcs_vm.py +++ b/gns3server/compute/vpcs/vpcs_vm.py @@ -512,6 +512,42 @@ class VPCSVM(BaseNode): ) ) + async def start_marker(self, port_number, name, bpf, pcap_path, tag=None): + """ + Attach a traffic-insight ``mark`` filter to this node's uBridge bridge. + On BPF match uBridge emits a MARK signal and appends the packet to the pcap. + + :param port_number: port number (kept for API symmetry; VPCS has a single bridge) + :param name: stable filter name — pcap identity and echoed in MARK 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 + """ + + if self.ubridge: + await self._ubridge_add_marker_filter(f"VPCS-{self._id}", name, bpf, pcap_path, tag) + log.info( + "VPCS '{name}' [{id}]: starting marker '{marker}' on port {port_number}".format( + name=self.name, id=self.id, marker=name, port_number=port_number + ) + ) + + async def stop_marker(self, port_number, name): + """ + Remove a traffic-insight ``mark`` filter from this node's uBridge bridge. + + :param port_number: port number (kept for API symmetry) + :param name: filter name previously passed to start_marker + """ + + if self.ubridge: + await self._ubridge_delete_marker_filter(f"VPCS-{self._id}", name) + log.info( + "VPCS '{name}' [{id}]: stopping marker '{marker}' on port {port_number}".format( + name=self.name, id=self.id, marker=name, port_number=port_number + ) + ) + def _build_command(self): """ Command to start the VPCS process. diff --git a/gns3server/controller/link.py b/gns3server/controller/link.py index 556f5ba5f..0e959cef8 100644 --- a/gns3server/controller/link.py +++ b/gns3server/controller/link.py @@ -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,13 @@ class Link: """ return self._filters + @property + def markers(self): + """ + Get the traffic insight markers dict: name → {bpf, tag, enabled} + """ + return self._markers + @property def show_filters_icon(self): """ @@ -298,6 +306,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 +600,7 @@ class Link: "nodes": res, "link_id": self._id, "filters": self._filters, + "markers": self._markers, "link_style": self._link_style, "suspend": self._suspended, "show_filters_icon": getattr(self, '_show_filters_icon', True), @@ -585,6 +615,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, diff --git a/gns3server/controller/udp_link.py b/gns3server/controller/udp_link.py index 7c2e6f187..42e34ae47 100644 --- a/gns3server/controller/udp_link.py +++ b/gns3server/controller/udp_link.py @@ -19,6 +19,7 @@ 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 class UDPLink(Link): @@ -26,6 +27,8 @@ class UDPLink(Link): super().__init__(project, link_id=link_id) self._created = False self._link_data = [] + # Runtime-only Node references for marker commands (not serialized). + self._marker_capture_nodes = {} @property def debug_link_data(self): @@ -251,3 +254,132 @@ class UDPLink(Link): """ if self._capture_node and node == self._capture_node["node"] and node.status != "started": await self.stop_capture() + # Tear down any marker whose capture-side node just stopped. + for name, marker_info in list(self._markers.items()): + if marker_info.get("capture_node_id") == node.id and node.status != "started": + await self.stop_marker(name) + + def _capture_node_for_marker(self, name): + """Return the stored (node, adapter_number, port_number) for a marker's capture side.""" + return self._marker_capture_nodes.get(name) + + def _store_capture_node_for_marker(self, name, capture_side): + """Persist the capture-side identity (serializable refs) + runtime Node.""" + self._markers[name] = { + **self._markers.get(name, {}), + "capture_node_id": capture_side["node"].id, + "capture_adapter": capture_side["adapter_number"], + "capture_port": capture_side["port_number"], + } + self._marker_capture_nodes[name] = capture_side + + async def start_marker(self, name, bpf, tag=None): + """ + Attach a traffic-insight marker to this link. + + :param name: stable filter name — echoed in MARK signals + pcap identity + :param bpf: libpcap BPF expression + :param tag: optional correlation id + """ + + if name in self._markers: + raise ControllerError(f"Marker '{name}' already exists on link {self._id}") + + # Pre-validate BPF on the controller side before reaching ubridge. + result = validate_bpf_syntax(bpf) + if not result.get("valid"): + raise ControllerError(f"Invalid BPF expression: {result.get('error', 'unknown error')}") + + capture_side = self._choose_capture_side() + data = {"name": name, "bpf": bpf, "tag": tag, "link_id": self._id} + await capture_side["node"].post( + "/adapters/{adapter_number}/ports/{port_number}/markers/start".format( + adapter_number=capture_side["adapter_number"], port_number=capture_side["port_number"] + ), + data=data, + ) + self._store_capture_node_for_marker(name, capture_side) + self._markers[name].update({"bpf": bpf, "tag": tag, "enabled": True}) + self._project.emit_notification("link.updated", self.asdict()) + self._project.dump() + + async def stop_marker(self, name): + """ + Remove a traffic-insight marker from this link. + + :param name: filter name to remove + """ + + if name not in self._markers: + raise ControllerNotFoundError(f"Marker '{name}' not found on link {self._id}") + + capture_side = self._marker_capture_nodes.get(name) + if capture_side: + await capture_side["node"].post( + "/adapters/{adapter_number}/ports/{port_number}/markers/stop".format( + adapter_number=capture_side["adapter_number"], + port_number=capture_side["port_number"], + ), + data={"name": name}, + ) + self._markers.pop(name, None) + self._marker_capture_nodes.pop(name, None) + self._project.emit_notification("link.updated", self.asdict()) + self._project.dump() + + async def update_marker(self, name, bpf=None, tag=None, enabled=None): + """ + Update an existing marker. A BPF change requires delete+re-add so the + ubridge side flushes the pcap and the new filter takes effect. + + :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) + """ + + marker_info = self._markers.get(name) + if not marker_info: + raise ControllerNotFoundError(f"Marker '{name}' not found on link {self._id}") + + new_bpf = bpf if bpf is not None else marker_info["bpf"] + new_tag = tag if tag is not None else marker_info.get("tag") + new_enabled = enabled if enabled is not None else marker_info.get("enabled", True) + + if not new_enabled and marker_info.get("enabled", True): + # Toggle off: remove from ubridge but keep state. + await self.stop_marker(name) + self._markers[name] = {**marker_info, "bpf": new_bpf, "tag": new_tag, "enabled": False} + self._project.emit_notification("link.updated", self.asdict()) + self._project.dump() + return + + capture_side = self._marker_capture_nodes.get(name) + if new_bpf != marker_info.get("bpf") or new_tag != marker_info.get("tag"): + # BPF or tag changed — re-validate, delete, re-add. + if new_bpf != marker_info.get("bpf"): + result = validate_bpf_syntax(new_bpf) + if not result.get("valid"): + raise ControllerError(f"Invalid BPF expression: {result.get('error', 'unknown error')}") + if capture_side: + # Delete old filter from ubridge. + await capture_side["node"].post( + "/adapters/{adapter_number}/ports/{port_number}/markers/stop".format( + adapter_number=capture_side["adapter_number"], + port_number=capture_side["port_number"], + ), + data={"name": name}, + ) + # Re-add with new params. + data = {"name": name, "bpf": new_bpf, "tag": new_tag, "link_id": self._id} + await capture_side["node"].post( + "/adapters/{adapter_number}/ports/{port_number}/markers/start".format( + adapter_number=capture_side["adapter_number"], + port_number=capture_side["port_number"], + ), + data=data, + ) + self._markers[name] = {**marker_info, "bpf": new_bpf, "tag": new_tag, "enabled": True} + + self._project.emit_notification("link.updated", self.asdict()) + self._project.dump() diff --git a/gns3server/core/tasks.py b/gns3server/core/tasks.py index d4968d8f5..9fc911d55 100644 --- a/gns3server/core/tasks.py +++ b/gns3server/core/tasks.py @@ -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: diff --git a/gns3server/schemas/__init__.py b/gns3server/schemas/__init__.py index d228f4b84..3c8d0ac63 100644 --- a/gns3server/schemas/__init__.py +++ b/gns3server/schemas/__init__.py @@ -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, MarkerDelete 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 diff --git a/gns3server/schemas/config.py b/gns3server/schemas/config.py index 294ea4cf6..6b00bff75 100644 --- a/gns3server/schemas/config.py +++ b/gns3server/schemas/config.py @@ -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(0, ge=0, le=65535) compute_username: str = "gns3" compute_password: SecretStr = SecretStr("") allowed_interfaces: List[str] = Field(default_factory=list) diff --git a/gns3server/schemas/controller/links.py b/gns3server/schemas/controller/links.py index be6846bb8..7a0676e04 100644 --- a/gns3server/schemas/controller/links.py +++ b/gns3server/schemas/controller/links.py @@ -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,25 @@ 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] = None + bpf: str + tag: Optional[int] = None + link_id: Optional[str] = None + + +class MarkerDelete(BaseModel): + """ + Body for removing a traffic-insight marker from a link. + """ + + name: str diff --git a/tests/compute/marker/__init__.py b/tests/compute/marker/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/compute/marker/test_marker_manager.py b/tests/compute/marker/test_marker_manager.py new file mode 100644 index 000000000..dc8e826ff --- /dev/null +++ b/tests/compute/marker/test_marker_manager.py @@ -0,0 +1,218 @@ +#!/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 . + +import asyncio +import pytest + +from gns3server.compute.marker.marker_manager import MarkerManager +from gns3server.compute.marker.marker_listener import MarkerListener + + +# --------------------------------------------------------------------------- +# Registry +# --------------------------------------------------------------------------- + +class TestMarkerRegistry: + + def test_register_and_lookup(self): + MarkerManager.reset() + mgr = MarkerManager.instance() + mgr.register("proj1", "node1", "filter1", "link1", tag=5) + pid, lid, tag = mgr.lookup("node1", "filter1") + assert pid == "proj1" + assert lid == "link1" + assert tag == 5 + + def test_miss_returns_none(self): + MarkerManager.reset() + mgr = MarkerManager.instance() + pid, lid, tag = mgr.lookup("no-such-node", "no-such-filter") + assert pid is None + + def test_reregister_updates(self): + MarkerManager.reset() + mgr = MarkerManager.instance() + mgr.register("p", "n", "f", "l", tag=1) + mgr.register("p", "n", "f", "l", tag=99) + _, _, tag = mgr.lookup("n", "f") + assert tag == 99 + + def test_unregister(self): + MarkerManager.reset() + mgr = MarkerManager.instance() + mgr.register("p", "n", "f", "l") + assert mgr.unregister("n", "f") is True + pid, _, _ = mgr.lookup("n", "f") + assert pid is None + assert mgr.unregister("n", "f") is False + + def test_unregister_project(self): + MarkerManager.reset() + mgr = MarkerManager.instance() + mgr.register("p1", "n1", "f1", "l1") + mgr.register("p1", "n2", "f2", "l2") + mgr.register("p2", "n3", "f3", "l3") + mgr.unregister_project("p1") + assert mgr.lookup("n1", "f1") == (None, None, None) + assert mgr.lookup("n2", "f2") == (None, None, None) + assert mgr.lookup("n3", "f3")[0] == "p2" + + def test_re_add_after_project_clear(self): + MarkerManager.reset() + mgr = MarkerManager.instance() + mgr.register("p", "n", "f", "l") + mgr.unregister_project("p") + mgr.register("p", "n", "f", "l2", tag=42) + pid, lid, tag = mgr.lookup("n", "f") + assert pid == "p" and lid == "l2" and tag == 42 + + +# --------------------------------------------------------------------------- +# MarkerListener parsing +# --------------------------------------------------------------------------- + +class FakeMarkerManager: + def __init__(self): + self.events = [] + self._entries = {} + + def lookup(self, node_id, filter_name): + e = self._entries.get((node_id, filter_name)) + if e is None: + return None, None, None + return e["project_id"], e["link_id"], e["tag"] + + def emit_match(self, project_id, event): + self.events.append((project_id, event)) + + def register(self, project_id, node_id, filter_name, link_id, tag): + self._entries[(node_id, filter_name)] = { + "project_id": project_id, "link_id": link_id, "tag": tag + } + + +class TestMarkerListener: + + def test_parses_valid_mark_datagram(self): + fmgr = FakeMarkerManager() + fmgr.register("p1", "n1", "f1", "l1", tag=7) + lis = MarkerListener(fmgr) + lis.connection_made(None) + lis.datagram_received( + b"MARK 1700000000.123456 node=n1 filter=f1 tag=7 len=98\n", + ("127.0.0.1", 9999), + ) + assert len(fmgr.events) == 1 + _, ev = fmgr.events[0] + assert ev["node_id"] == "n1" + assert ev["link_id"] == "l1" + assert ev["filter"] == "f1" + assert ev["tag"] == "7" + assert ev["ts"] == pytest.approx(1700000000.123456) + assert ev["len"] == 98 + + def test_unknown_node_dropped(self): + fmgr = FakeMarkerManager() + lis = MarkerListener(fmgr) + lis.connection_made(None) + lis.datagram_received(b"MARK 1.0 node=bad filter=bad len=10\n", None) + assert fmgr.events == [] + + def test_bad_timestamp_ignored(self): + fmgr = FakeMarkerManager() + lis = MarkerListener(fmgr) + lis.connection_made(None) + lis.datagram_received(b"MARK badts node=n filter=f len=1\n", None) + assert fmgr.events == [] + + def test_not_mark_line_ignored(self): + fmgr = FakeMarkerManager() + lis = MarkerListener(fmgr) + lis.connection_made(None) + lis.datagram_received(b"HELLO world\n", None) + assert fmgr.events == [] + + def test_missing_node_ignored(self): + fmgr = FakeMarkerManager() + lis = MarkerListener(fmgr) + lis.connection_made(None) + lis.datagram_received(b"MARK 1.0 filter=f len=1\n", None) + assert fmgr.events == [] + + def test_tag_dash_falls_back_to_registered(self): + fmgr = FakeMarkerManager() + fmgr.register("p", "n", "f", "l", tag=42) + lis = MarkerListener(fmgr) + lis.connection_made(None) + lis.datagram_received(b"MARK 2.0 node=n filter=f tag=- len=20\n", None) + assert fmgr.events[0][1]["tag"] == 42 + + def test_exception_does_not_kill_listener(self): + fmgr = FakeMarkerManager() + lis = MarkerListener(fmgr) + lis.connection_made(None) + # Non-decodable bytes + lis.datagram_received(b"\xff\xfe\xfd", None) + # The listener swallows exceptions; reaching here proves it survived. + assert True + + +# --------------------------------------------------------------------------- +# UDP round-trip +# --------------------------------------------------------------------------- + +@pytest.mark.asyncio +class TestMarkerManagerUDP: + + async def test_listener_receives_and_dispatches(self): + MarkerManager.reset() + mgr = MarkerManager.instance() + + captured = [] + original_emit = mgr.emit_match + mgr.emit_match = lambda pid, ev: captured.append((pid, ev)) + + await mgr.start("127.0.0.1", 0) + assert mgr.running + assert mgr.port is not None + + mgr.register("proj-rt", "node-rt", "filt-rt", "link-rt", tag=10) + + loop = asyncio.get_running_loop() + + class SendProto(asyncio.DatagramProtocol): + def connection_made(self, transport): + self.transport = transport + + sp = SendProto() + transport, _ = await loop.create_datagram_endpoint( + lambda: sp, remote_addr=("127.0.0.1", mgr.port) + ) + transport.sendto( + b"MARK 123.456 node=node-rt filter=filt-rt tag=10 len=88\n" + ) + await asyncio.sleep(0.15) + transport.close() + + mgr.emit_match = original_emit + await mgr.stop() + + assert len(captured) == 1 + pid, ev = captured[0] + assert pid == "proj-rt" + assert ev["link_id"] == "link-rt" + assert ev["len"] == 88 diff --git a/tests/controller/test_link.py b/tests/controller/test_link.py index 239682948..54d1db5ba 100644 --- a/tests/controller/test_link.py +++ b/tests/controller/test_link.py @@ -221,6 +221,7 @@ async def test_json(project, compute): } ], "filters": {}, + "markers": {}, "show_filters_icon": True, "link_style": {}, "suspend": False, @@ -255,6 +256,7 @@ async def test_json(project, compute): ], "link_style": {}, "filters": {}, + "markers": {}, "show_filters_icon": True, "suspend": False } From 88465e872c926ee4899121b0f230dce1c04fab20 Mon Sep 17 00:00:00 2001 From: YueGuobin Date: Sun, 12 Jul 2026 01:15:39 +0800 Subject: [PATCH 02/31] fix(marker): add _choose_marker_side to exclude non-uBridge node types MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit _choose_capture_side prefers BUILTIN_NODE_TYPES which includes ethernet_switch/ethernet_hub/NAT etc. — nodes that do not use uBridge and therefore have no bridge for a marker filter to attach to. Add _choose_marker_side with a _MARKER_UNSUPPORTED_TYPES blocklist so marker capture-side selection skips NIO-only switches and picks the uBridge-backed node (VPCS/QEMU/Docker/etc.) instead. Verified end-to-end: PC1↔Switch↔PC2 links with arp markers correctly pick VPCS nodes; MARK signals flow through to WebSocket and pcaps are written. --- gns3server/controller/udp_link.py | 49 ++++++++++++++++++++++++++++--- 1 file changed, 45 insertions(+), 4 deletions(-) diff --git a/gns3server/controller/udp_link.py b/gns3server/controller/udp_link.py index 42e34ae47..d0db96395 100644 --- a/gns3server/controller/udp_link.py +++ b/gns3server/controller/udp_link.py @@ -21,6 +21,11 @@ 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. +_MARKER_UNSUPPORTED_TYPES = frozenset({ + "ethernet_switch", "ethernet_hub", "frame_relay_switch", "atm_switch", "nat", +}) + class UDPLink(Link): def __init__(self, project, link_id=None): @@ -248,6 +253,42 @@ class UDPLink(Link): raise ControllerError("Cannot capture because there is no running device on this link") + def _choose_marker_side(self): + """ + Pick a marker capture side, excluding node types without a uBridge bridge + (ethernet_switch, ethernet_hub, etc.). Falls through the same + preference tiers as ``_choose_capture_side``. + """ + + # Prefer local + non-switch + started. + for node in self._nodes: + if ( + node["node"].compute.id == "local" + and node["node"].node_type not in _MARKER_UNSUPPORTED_TYPES + and node["node"].status == "started" + ): + return node + + # Non-switch + started (any compute). + for node in self._nodes: + if ( + node["node"].node_type not in _MARKER_UNSUPPORTED_TYPES + and node["node"].status == "started" + ): + return node + + # Fallback: any local started. + for node in self._nodes: + if node["node"].compute.id == "local" and node["node"].status == "started": + return node + + # Last resort: any started. + for node in self._nodes: + if node["node"].status == "started": + return node + + raise ControllerError("Cannot add marker because there is no running device on this link") + async def node_updated(self, node): """ Called when a node member of the link is updated @@ -290,15 +331,15 @@ class UDPLink(Link): if not result.get("valid"): raise ControllerError(f"Invalid BPF expression: {result.get('error', 'unknown error')}") - capture_side = self._choose_capture_side() + marker_side = self._choose_marker_side() data = {"name": name, "bpf": bpf, "tag": tag, "link_id": self._id} - await capture_side["node"].post( + await marker_side["node"].post( "/adapters/{adapter_number}/ports/{port_number}/markers/start".format( - adapter_number=capture_side["adapter_number"], port_number=capture_side["port_number"] + adapter_number=marker_side["adapter_number"], port_number=marker_side["port_number"] ), data=data, ) - self._store_capture_node_for_marker(name, capture_side) + self._store_capture_node_for_marker(name, marker_side) self._markers[name].update({"bpf": bpf, "tag": tag, "enabled": True}) self._project.emit_notification("link.updated", self.asdict()) self._project.dump() From 540e1c5678d2bb82166ed8f2743c0707686b27a3 Mon Sep 17 00:00:00 2001 From: YueGuobin Date: Sun, 12 Jul 2026 01:19:29 +0800 Subject: [PATCH 03/31] config: set marker_listen_port default to 3070 and document in config templates Change the marker UDP sink default from port=0 (OS-chosen) to 3070 for predictable firewall/debugging/monitoring. Add marker_listen_host and marker_listen_port with documentation to config_samples/gns3_server.conf and scripts/gns3_server.conf. --- gns3server/config_samples/gns3_server.conf | 7 +++++++ gns3server/schemas/config.py | 2 +- scripts/gns3_server.conf | 4 ++++ 3 files changed, 12 insertions(+), 1 deletion(-) diff --git a/gns3server/config_samples/gns3_server.conf b/gns3server/config_samples/gns3_server.conf index 276987c45..ca672891d 100644 --- a/gns3server/config_samples/gns3_server.conf +++ b/gns3server/config_samples/gns3_server.conf @@ -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 diff --git a/gns3server/schemas/config.py b/gns3server/schemas/config.py index 6b00bff75..12e57b7a2 100644 --- a/gns3server/schemas/config.py +++ b/gns3server/schemas/config.py @@ -158,7 +158,7 @@ class ServerSettings(BaseModel): # 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(0, ge=0, le=65535) + 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) diff --git a/scripts/gns3_server.conf b/scripts/gns3_server.conf index 196ad472d..b8b367e7f 100644 --- a/scripts/gns3_server.conf +++ b/scripts/gns3_server.conf @@ -4,3 +4,7 @@ compute_password = gns3 skills_repo_url = https://github.com/yueguobin/GNS3-Skills.git skills_repo_branch = main skills_auto_update = false + +; Marker (traffic-insight) UDP sink port for uBridge MARK signals +; Set to 0 for OS-chosen port +marker_listen_port = 3070 From 7c90fa9e6484f9dd5e81e7dd451073a4cae9480b Mon Sep 17 00:00:00 2001 From: YueGuobin Date: Sun, 12 Jul 2026 14:18:29 +0800 Subject: [PATCH 04/31] fix(marker): ride markers on NIO so they survive node restart and project reload MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mirror the packet-filter lifecycle: marker specs now live on the NIO (next to filters), ride in link_data from controller to compute on every NIO create/update, and are reapplied by _ubridge_apply_markers in add_ubridge_udp_connection (bridge creation / node restart) and update_ubridge_udp_connection (NIO update — following the preceding reset_packet_filters so markers survive filter changes). Changes: - NIO / NIOUDP: _markers property + asdict - schemas/compute/nios.py: UDPNIO.markers field - base_manager.create_nio: nio.markers from settings - PUT /nio routes (vpcs/qemu/docker): nio.markers update - base_node: _ubridge_apply_markers(bridge_name, nio) iterates nio.markers, computes pcap path, calls _ubridge_add_marker_filter + MarkerManager.register; called after _ubridge_apply_filters - controller udp_link: _get_node_markers + _markers_for_node (route by capture_node_id); markers in create() and update() link_data - /markers/start,stop endpoints: mirror spec onto nio.markers so the marker survives a subsequent node stop/start without a PUT - tests: add markers field to NIO data expectations This covers: - Node restart: NIO persists, add_ubridge_udp_connection re-applies - Filter update: reset wipes markers, _ubridge_apply_markers re-adds - Project reload: create() carries markers in link_data → create_nio - Immediate create: endpoint sets nio.markers immediately --- gns3server/api/routes/compute/docker_nodes.py | 9 ++++++ gns3server/api/routes/compute/qemu_nodes.py | 9 ++++++ gns3server/api/routes/compute/vpcs_nodes.py | 13 ++++++++ gns3server/compute/base_manager.py | 1 + gns3server/compute/base_node.py | 30 +++++++++++++++++++ gns3server/compute/nios/nio.py | 22 ++++++++++++++ gns3server/compute/nios/nio_udp.py | 3 +- gns3server/controller/udp_link.py | 28 ++++++++++++++++- gns3server/schemas/compute/nios.py | 1 + tests/controller/test_udp_link.py | 9 ++++++ 10 files changed, 123 insertions(+), 2 deletions(-) diff --git a/gns3server/api/routes/compute/docker_nodes.py b/gns3server/api/routes/compute/docker_nodes.py index f8e8b8bb6..eb2ba1193 100644 --- a/gns3server/api/routes/compute/docker_nodes.py +++ b/gns3server/api/routes/compute/docker_nodes.py @@ -294,6 +294,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() @@ -378,6 +379,11 @@ async def start_docker_node_marker( MarkerManager.instance().register( str(project_id), node.id, marker_data.name, marker_data.link_id, marker_data.tag ) + nio = node.get_nio(adapter_number) + if nio: + nio.markers[marker_data.name] = { + "bpf": marker_data.bpf, "tag": marker_data.tag, "link_id": marker_data.link_id + } return {"pcap_file_path": str(pcap_path)} @@ -398,6 +404,9 @@ async def stop_docker_node_marker( await node.stop_marker(adapter_number, marker_data.name) MarkerManager.instance().unregister(node.id, marker_data.name) + nio = node.get_nio(adapter_number) + if nio: + nio.markers.pop(marker_data.name, None) @router.get( diff --git a/gns3server/api/routes/compute/qemu_nodes.py b/gns3server/api/routes/compute/qemu_nodes.py index 6c8456eeb..e0117339a 100644 --- a/gns3server/api/routes/compute/qemu_nodes.py +++ b/gns3server/api/routes/compute/qemu_nodes.py @@ -322,6 +322,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() @@ -407,6 +408,11 @@ async def start_qemu_node_marker( MarkerManager.instance().register( str(project_id), node.id, marker_data.name, marker_data.link_id, marker_data.tag ) + nio = node.get_nio(adapter_number) + if nio: + nio.markers[marker_data.name] = { + "bpf": marker_data.bpf, "tag": marker_data.tag, "link_id": marker_data.link_id + } return {"pcap_file_path": str(pcap_path)} @@ -427,6 +433,9 @@ async def stop_qemu_node_marker( await node.stop_marker(adapter_number, marker_data.name) MarkerManager.instance().unregister(node.id, marker_data.name) + nio = node.get_nio(adapter_number) + if nio: + nio.markers.pop(marker_data.name, None) @router.get( diff --git a/gns3server/api/routes/compute/vpcs_nodes.py b/gns3server/api/routes/compute/vpcs_nodes.py index 60360f579..74b349959 100644 --- a/gns3server/api/routes/compute/vpcs_nodes.py +++ b/gns3server/api/routes/compute/vpcs_nodes.py @@ -241,6 +241,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() @@ -329,6 +330,15 @@ async def start_vpcs_node_marker( MarkerManager.instance().register( str(project_id), node.id, marker_data.name, marker_data.link_id, marker_data.tag ) + # Mirror the marker spec onto the NIO so it survives a node restart (the NIO + # persists across stop/start and _ubridge_apply_markers re-applies its markers). + nio = node.get_nio(port_number) + if nio: + nio.markers[marker_data.name] = { + "bpf": marker_data.bpf, + "tag": marker_data.tag, + "link_id": marker_data.link_id, + } return {"pcap_file_path": pcap_path} @@ -350,6 +360,9 @@ async def stop_vpcs_node_marker( await node.stop_marker(port_number, marker_data.name) MarkerManager.instance().unregister(node.id, marker_data.name) + nio = node.get_nio(port_number) + if nio: + nio.markers.pop(marker_data.name, None) @router.get( diff --git a/gns3server/compute/base_manager.py b/gns3server/compute/base_manager.py index 076b802d1..8ef27265c 100644 --- a/gns3server/compute/base_manager.py +++ b/gns3server/compute/base_manager.py @@ -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"] diff --git a/gns3server/compute/base_node.py b/gns3server/compute/base_node.py index fc2701ab0..3618fea30 100644 --- a/gns3server/compute/base_node.py +++ b/gns3server/compute/base_node.py @@ -1012,10 +1012,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): """ @@ -1112,6 +1114,34 @@ class BaseNode: await self._ubridge_send(f"bridge delete_packet_filter {bridge_name} {name}") + 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" + ) + await self._ubridge_add_marker_filter(bridge_name, name, bpf, pcap_path, tag) + 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. diff --git a/gns3server/compute/nios/nio.py b/gns3server/compute/nios/nio.py index 8ad5bd870..6fe57a130 100644 --- a/gns3server/compute/nios/nio.py +++ b/gns3server/compute/nios/nio.py @@ -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 diff --git a/gns3server/compute/nios/nio_udp.py b/gns3server/compute/nios/nio_udp.py index b7736a39e..e6f1bd8bc 100644 --- a/gns3server/compute/nios/nio_udp.py +++ b/gns3server/compute/nios/nio_udp.py @@ -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 } diff --git a/gns3server/controller/udp_link.py b/gns3server/controller/udp_link.py index d0db96395..ff17069d1 100644 --- a/gns3server/controller/udp_link.py +++ b/gns3server/controller/udp_link.py @@ -45,7 +45,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() @@ -54,6 +54,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 @@ -88,6 +108,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( @@ -97,6 +118,7 @@ class UDPLink(Link): "rport": self._node2_port, "type": "nio_udp", "filters": node1_filters, + "markers": node1_markers, "suspend": self._suspended, } ) @@ -109,6 +131,7 @@ class UDPLink(Link): "rport": self._node1_port, "type": "nio_udp", "filters": node2_filters, + "markers": node2_markers, "suspend": self._suspended, } ) @@ -133,10 +156,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( @@ -146,6 +171,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( diff --git a/gns3server/schemas/compute/nios.py b/gns3server/schemas/compute/nios.py index cf69f9e30..f5cf5f073 100644 --- a/gns3server/schemas/compute/nios.py +++ b/gns3server/schemas/compute/nios.py @@ -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): diff --git a/tests/controller/test_udp_link.py b/tests/controller/test_udp_link.py index fb03947c9..e322627a0 100644 --- a/tests/controller/test_udp_link.py +++ b/tests/controller/test_udp_link.py @@ -78,6 +78,7 @@ async def test_create(project): "rport": 2048, "type": "nio_udp", "filters": {"delay": [10, 0]}, + "markers": {}, "suspend": False, }, timeout=120) @@ -87,6 +88,7 @@ async def test_create(project): "rport": 1024, "type": "nio_udp", "filters": {}, + "markers": {}, "suspend": False, }, timeout=120) @@ -146,6 +148,7 @@ async def test_create_one_side_failure(project): "rport": 2048, "type": "nio_udp", "filters": {}, + "markers": {}, "suspend": False, }, timeout=120) @@ -155,6 +158,7 @@ async def test_create_one_side_failure(project): "rport": 1024, "type": "nio_udp", "filters": {}, + "markers": {}, "suspend": False, }, timeout=120) # The link creation has failed we rollback the nio @@ -345,6 +349,7 @@ async def test_update(project): "rport": 2048, "type": "nio_udp", "suspend": False, + "markers": {}, "filters": {"delay": [10, 0]} }, timeout=120) @@ -354,6 +359,7 @@ async def test_update(project): "rport": 1024, "type": "nio_udp", "suspend": False, + "markers": {}, "filters": {} }, timeout=120) @@ -365,6 +371,7 @@ async def test_update(project): "rport": 2048, "type": "nio_udp", "suspend": False, + "markers": {}, "filters": { "frequency_drop": [5], "bpf": ["icmp[icmptype] == 8"] @@ -425,6 +432,7 @@ async def test_update_suspend(project): "rport": 2048, "type": "nio_udp", "filters": {"frequency_drop": [-1]}, + "markers": {}, "suspend": True }, timeout=120) @@ -434,5 +442,6 @@ async def test_update_suspend(project): "rport": 1024, "type": "nio_udp", "filters": {}, + "markers": {}, "suspend": True }, timeout=120) From 239f42f5aa2203e0d6bf891b4faa75bc8660dc91 Mon Sep 17 00:00:00 2001 From: YueGuobin Date: Sun, 12 Jul 2026 14:43:06 +0800 Subject: [PATCH 05/31] fix(marker): stop auto-deleting markers on node stop in node_updated MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Markers are persistent link-scoped features (like packet filters), not transient runtime features (like capture). Tearing them down on every node stop breaks stop/start restore: the NIO-based re-application in _ubridge_apply_markers works correctly on restart, but node_updated was prematurely calling stop_marker which erased _markers state and dumped an empty topology. Now markers survive node stop transparently — the ubridge filter dies with the process but is re-added from nio.markers in add_ubridge_udp_connection when the node starts again, mirroring exactly how packet filters behave. --- gns3server/controller/udp_link.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/gns3server/controller/udp_link.py b/gns3server/controller/udp_link.py index ff17069d1..20dd6ae3b 100644 --- a/gns3server/controller/udp_link.py +++ b/gns3server/controller/udp_link.py @@ -321,10 +321,11 @@ class UDPLink(Link): """ if self._capture_node and node == self._capture_node["node"] and node.status != "started": await self.stop_capture() - # Tear down any marker whose capture-side node just stopped. - for name, marker_info in list(self._markers.items()): - if marker_info.get("capture_node_id") == node.id and node.status != "started": - await self.stop_marker(name) + # 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. def _capture_node_for_marker(self, name): """Return the stored (node, adapter_number, port_number) for a marker's capture side.""" From b8f49696ccb6cb52ca7b556f56b15d9ae634098e Mon Sep 17 00:00:00 2001 From: YueGuobin Date: Sun, 12 Jul 2026 14:54:31 +0800 Subject: [PATCH 06/31] fix(marker): use allowlist in _choose_marker_side, align with _get_filter_node MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the complex tier-based fallback (which leaked into unsupported switch types on the final tiers) with a simple allowlist of _MARKER_CAPABLE_TYPES, mirroring _get_filter_node in link.py. Also accept stopped nodes — like packet filters, markers are stored on the NIO and applied when the node starts via _ubridge_apply_markers. --- gns3server/controller/udp_link.py | 42 +++++++++++++------------------ 1 file changed, 18 insertions(+), 24 deletions(-) diff --git a/gns3server/controller/udp_link.py b/gns3server/controller/udp_link.py index 20dd6ae3b..73f006cea 100644 --- a/gns3server/controller/udp_link.py +++ b/gns3server/controller/udp_link.py @@ -22,8 +22,11 @@ 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. -_MARKER_UNSUPPORTED_TYPES = frozenset({ - "ethernet_switch", "ethernet_hub", "frame_relay_switch", "atm_switch", "nat", +# 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", "virtualbox", "vmware", "dynamips", "cloud", }) @@ -281,39 +284,30 @@ class UDPLink(Link): def _choose_marker_side(self): """ - Pick a marker capture side, excluding node types without a uBridge bridge - (ethernet_switch, ethernet_hub, etc.). Falls through the same - preference tiers as ``_choose_capture_side``. + 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 local + non-switch + started. + # Prefer started. for node in self._nodes: if ( - node["node"].compute.id == "local" - and node["node"].node_type not in _MARKER_UNSUPPORTED_TYPES + node["node"].node_type in _MARKER_CAPABLE_TYPES and node["node"].status == "started" ): return node - # Non-switch + started (any compute). + # Accept stopped but capable (marker rides NIO, applied at start). for node in self._nodes: - if ( - node["node"].node_type not in _MARKER_UNSUPPORTED_TYPES - and node["node"].status == "started" - ): + if node["node"].node_type in _MARKER_CAPABLE_TYPES: return node - # Fallback: any local started. - for node in self._nodes: - if node["node"].compute.id == "local" and node["node"].status == "started": - return node - - # Last resort: any started. - for node in self._nodes: - if node["node"].status == "started": - return node - - raise ControllerError("Cannot add marker because there is no running device on this link") + raise ControllerError( + "Cannot add marker because no device on this link supports " + "traffic insight" + ) async def node_updated(self, node): """ From 3f15ac38f6e1d5df2fc40d826981a28893330839 Mon Sep 17 00:00:00 2001 From: YueGuobin Date: Sun, 12 Jul 2026 21:47:46 +0800 Subject: [PATCH 07/31] feat(marker): add optional color field to marker data model MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a user-chosen hex color (e.g. '#ff5722') to each marker, stored in Link._markers alongside bpf/tag/enabled. The color: - Is serialized with the link (asdict) and persisted in the topology, so it survives project reload and is consistent across devices. - Never reaches uBridge (compute ignores it) — it is purely a Web UI concern. - Can be updated independently of the BPF (color-only changes skip the ubridge round-trip). MarkerCreate schema gains color: Optional[str]; start_marker and update_marker carry it through; REST create/update endpoints pass it. The marker.match WebSocket event stays lean (no color) — the Web UI maps filter -> color from the link state it already holds. --- gns3server/api/routes/controller/links.py | 7 +++++-- gns3server/controller/udp_link.py | 23 ++++++++++++++++++----- gns3server/schemas/controller/links.py | 4 ++++ 3 files changed, 27 insertions(+), 7 deletions(-) diff --git a/gns3server/api/routes/controller/links.py b/gns3server/api/routes/controller/links.py index bb723927a..ec5e4f58a 100644 --- a/gns3server/api/routes/controller/links.py +++ b/gns3server/api/routes/controller/links.py @@ -454,12 +454,14 @@ async def create_marker( Required privilege: Link.Modify """ + name = marker_data.name or f"marker-{link.id[:8]}" await link.start_marker( - name=marker_data.name or f"marker-{link.id[:8]}", + name=name, bpf=marker_data.bpf, tag=marker_data.tag, + color=marker_data.color, ) - return link.markers.get(marker_data.name, {}) + return link.markers.get(name, {}) @router.delete( @@ -499,6 +501,7 @@ async def update_marker( name=marker_name, bpf=marker_data.bpf if marker_data.bpf else None, tag=marker_data.tag, + color=marker_data.color, ) return link.markers.get(marker_name, {}) diff --git a/gns3server/controller/udp_link.py b/gns3server/controller/udp_link.py index 73f006cea..0ef0ea9ac 100644 --- a/gns3server/controller/udp_link.py +++ b/gns3server/controller/udp_link.py @@ -335,13 +335,15 @@ class UDPLink(Link): } self._marker_capture_nodes[name] = capture_side - async def start_marker(self, name, bpf, tag=None): + async def start_marker(self, name, bpf, tag=None, color=None): """ Attach a traffic-insight marker to this link. :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 """ if name in self._markers: @@ -361,7 +363,7 @@ class UDPLink(Link): data=data, ) self._store_capture_node_for_marker(name, marker_side) - self._markers[name].update({"bpf": bpf, "tag": tag, "enabled": True}) + self._markers[name].update({"bpf": bpf, "tag": tag, "enabled": True, "color": color}) self._project.emit_notification("link.updated", self.asdict()) self._project.dump() @@ -389,7 +391,7 @@ class UDPLink(Link): self._project.emit_notification("link.updated", self.asdict()) self._project.dump() - async def update_marker(self, name, bpf=None, tag=None, enabled=None): + async def update_marker(self, name, bpf=None, tag=None, enabled=None, color=None): """ Update an existing marker. A BPF change requires delete+re-add so the ubridge side flushes the pcap and the new filter takes effect. @@ -398,6 +400,7 @@ class UDPLink(Link): :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) """ marker_info = self._markers.get(name) @@ -407,11 +410,15 @@ class UDPLink(Link): new_bpf = bpf if bpf is not None else marker_info["bpf"] new_tag = tag if tag is not None else marker_info.get("tag") new_enabled = enabled if enabled is not None else marker_info.get("enabled", True) + new_color = color if color is not None else marker_info.get("color") if not new_enabled and marker_info.get("enabled", True): # Toggle off: remove from ubridge but keep state. await self.stop_marker(name) - self._markers[name] = {**marker_info, "bpf": new_bpf, "tag": new_tag, "enabled": False} + self._markers[name] = { + **marker_info, "bpf": new_bpf, "tag": new_tag, + "enabled": False, "color": new_color, + } self._project.emit_notification("link.updated", self.asdict()) self._project.dump() return @@ -441,7 +448,13 @@ class UDPLink(Link): ), data=data, ) - self._markers[name] = {**marker_info, "bpf": new_bpf, "tag": new_tag, "enabled": True} + self._markers[name] = { + **marker_info, "bpf": new_bpf, "tag": new_tag, + "enabled": True, "color": new_color, + } + elif new_color != marker_info.get("color"): + # Color-only change: no ubridge round-trip, just update state. + self._markers[name] = {**marker_info, "color": new_color} self._project.emit_notification("link.updated", self.asdict()) self._project.dump() diff --git a/gns3server/schemas/controller/links.py b/gns3server/schemas/controller/links.py index 7a0676e04..b21afae04 100644 --- a/gns3server/schemas/controller/links.py +++ b/gns3server/schemas/controller/links.py @@ -153,6 +153,10 @@ class MarkerCreate(BaseModel): 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'" + ) class MarkerDelete(BaseModel): From 88c03d143155f2d6ff560cf2283261ab69b4b236 Mon Sep 17 00:00:00 2001 From: YueGuobin Date: Sun, 12 Jul 2026 21:57:47 +0800 Subject: [PATCH 08/31] fix(marker): restore markers from topology on project load MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit _create_link_from_topology_data now restores traffic-insight markers (mirroring how filters are restored via update_filters), so markers — including their color — survive project close/reopen and server restart. Guard start_marker's uBridge POST with 'if self._created' (exactly as update_filters guards its update() call): during project load the link is not yet created, so only _markers state is recorded and the marker is applied once via the NIO flow in create()/_ubridge_apply_markers — no double application. --- gns3server/controller/project.py | 16 ++++++++++++++++ gns3server/controller/udp_link.py | 21 ++++++++++++++------- 2 files changed, 30 insertions(+), 7 deletions(-) diff --git a/gns3server/controller/project.py b/gns3server/controller/project.py index 989619940..b7e15db3b 100644 --- a/gns3server/controller/project.py +++ b/gns3server/controller/project.py @@ -765,6 +765,22 @@ class Project: "Dropping invalid filters on link %s: %s", link_data.get("link_id"), e ) + # Restore traffic-insight markers. Each marker's capture side is resolved + # when the link is (re)created and the marker is applied to uBridge via + # _ubridge_apply_markers in add_ubridge_udp_connection. + for name, marker in (link_data.get("markers") or {}).items(): + try: + await link.start_marker( + name=name, + bpf=marker["bpf"], + tag=marker.get("tag"), + color=marker.get("color"), + ) + except (ControllerError, KeyError) as e: + log.warning( + "Dropping marker %s on link %s: %s", + name, link_data.get("link_id"), e + ) if "link_style" in link_data: await link.update_link_style(link_data["link_style"]) if "show_filters_icon" in link_data: diff --git a/gns3server/controller/udp_link.py b/gns3server/controller/udp_link.py index 0ef0ea9ac..a0b28b79d 100644 --- a/gns3server/controller/udp_link.py +++ b/gns3server/controller/udp_link.py @@ -355,15 +355,22 @@ class UDPLink(Link): raise ControllerError(f"Invalid BPF expression: {result.get('error', 'unknown error')}") marker_side = self._choose_marker_side() - data = {"name": name, "bpf": bpf, "tag": tag, "link_id": self._id} - await marker_side["node"].post( - "/adapters/{adapter_number}/ports/{port_number}/markers/start".format( - adapter_number=marker_side["adapter_number"], port_number=marker_side["port_number"] - ), - data=data, - ) + # Record state + runtime capture-side ref unconditionally (so stop/update + # work and the marker is persisted), but only push to uBridge when the + # link is already live. During project load the link is not yet created + # (self._created is False); the marker then rides the NIO via create() + # and is applied once by _ubridge_apply_markers — mirroring exactly how + # update_filters guards its update() call. self._store_capture_node_for_marker(name, marker_side) self._markers[name].update({"bpf": bpf, "tag": tag, "enabled": True, "color": color}) + if self._created: + data = {"name": name, "bpf": bpf, "tag": tag, "link_id": self._id} + await marker_side["node"].post( + "/adapters/{adapter_number}/ports/{port_number}/markers/start".format( + adapter_number=marker_side["adapter_number"], port_number=marker_side["port_number"] + ), + data=data, + ) self._project.emit_notification("link.updated", self.asdict()) self._project.dump() From c62b9b0283e96bb2c6240b7541e4b60b4fd492e1 Mon Sep 17 00:00:00 2001 From: YueGuobin Date: Sun, 12 Jul 2026 22:16:40 +0800 Subject: [PATCH 09/31] refactor(marker): converge to filter single-path model, remove dual-apply endpoints MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Markers now follow exactly the same apply pattern as packet filters: state lives in Link._markers, application goes through NIO (update() -> PUT /nio -> _ubridge_apply_markers). The former immediate-apply REST endpoints (/markers/start, /markers/stop on the compute side) and the per-node start_marker/stop_marker methods are removed — they were a legacy of the original capture-inspired design and have been superseded by the NIO flow. Changes: - controller/udp_link: start_marker/stop_marker/update_marker now set _markers state + call self.update() (mirrors update_filters). Removed _marker_capture_nodes runtime dict and its helpers. - controller/project: _create_link_from_topology_data restores _markers directly from persisted data (with BPF validation, like filter reload). No long calls start_marker during load. - compute: _ubridge_apply_markers swallows BPF compile errors (warn+skip), matching _ubridge_apply_filters behaviour so a single bad expression cannot break link creation / node restart. - Removed: /markers/start,stop endpoints (6 handlers across vpcs/qemu/docker route files), node start_marker/stop_marker methods (3 VM files), _ubridge_delete_marker_filter, _marker_capture_nodes, MarkerDelete schema. Net: ~280 lines of dead code removed; marker and packet filter now share a single, unified apply path via the NIO. --- gns3server/api/routes/compute/docker_nodes.py | 56 -------- gns3server/api/routes/compute/qemu_nodes.py | 56 -------- gns3server/api/routes/compute/vpcs_nodes.py | 61 -------- gns3server/compute/base_node.py | 26 ++-- gns3server/compute/docker/docker_vm.py | 38 ----- gns3server/compute/qemu/qemu_vm.py | 38 ----- gns3server/compute/vpcs/vpcs_vm.py | 36 ----- gns3server/controller/project.py | 36 +++-- gns3server/controller/udp_link.py | 130 +++++------------- gns3server/schemas/__init__.py | 2 +- gns3server/schemas/controller/links.py | 6 - 11 files changed, 73 insertions(+), 412 deletions(-) diff --git a/gns3server/api/routes/compute/docker_nodes.py b/gns3server/api/routes/compute/docker_nodes.py index eb2ba1193..f08f9a497 100644 --- a/gns3server/api/routes/compute/docker_nodes.py +++ b/gns3server/api/routes/compute/docker_nodes.py @@ -29,8 +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 gns3server.compute.marker.marker_manager import MarkerManager - from .dependencies.authentication import compute_authentication, ws_compute_authentication responses = {404: {"model": schemas.ErrorMessage, "description": "Could not find project or Docker node"}} @@ -355,60 +353,6 @@ async def stop_docker_node_capture( await node.stop_capture(adapter_number) -@router.post( - "/{node_id}/adapters/{adapter_number}/ports/{port_number}/markers/start", - dependencies=[Depends(compute_authentication)] -) -async def start_docker_node_marker( - *, - project_id: UUID, - adapter_number: int, - port_number: int, - marker_data: schemas.MarkerCreate, - node: DockerVM = Depends(dep_node) -) -> dict: - """ - Attach a traffic-insight ``mark`` filter to the Docker node's uBridge bridge. - """ - - pcap_path = os.path.join( - node.project.markers_working_directory(), - f"{node.id}_{marker_data.link_id}_{marker_data.name}.pcap" - ) - await node.start_marker(adapter_number, marker_data.name, marker_data.bpf, pcap_path, marker_data.tag) - MarkerManager.instance().register( - str(project_id), node.id, marker_data.name, marker_data.link_id, marker_data.tag - ) - nio = node.get_nio(adapter_number) - if nio: - nio.markers[marker_data.name] = { - "bpf": marker_data.bpf, "tag": marker_data.tag, "link_id": marker_data.link_id - } - return {"pcap_file_path": str(pcap_path)} - - -@router.post( - "/{node_id}/adapters/{adapter_number}/ports/{port_number}/markers/stop", - status_code=status.HTTP_204_NO_CONTENT, - dependencies=[Depends(compute_authentication)] -) -async def stop_docker_node_marker( - adapter_number: int, - port_number: int, - marker_data: schemas.MarkerDelete, - node: DockerVM = Depends(dep_node) -) -> None: - """ - Remove a traffic-insight ``mark`` filter from the Docker node's uBridge bridge. - """ - - await node.stop_marker(adapter_number, marker_data.name) - MarkerManager.instance().unregister(node.id, marker_data.name) - nio = node.get_nio(adapter_number) - if nio: - nio.markers.pop(marker_data.name, None) - - @router.get( "/{node_id}/adapters/{adapter_number}/ports/{port_number}/capture/stream", dependencies=[Depends(compute_authentication)] diff --git a/gns3server/api/routes/compute/qemu_nodes.py b/gns3server/api/routes/compute/qemu_nodes.py index e0117339a..6582dc731 100644 --- a/gns3server/api/routes/compute/qemu_nodes.py +++ b/gns3server/api/routes/compute/qemu_nodes.py @@ -30,8 +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 gns3server.compute.marker.marker_manager import MarkerManager - from .dependencies.authentication import compute_authentication, ws_compute_authentication import logging @@ -384,60 +382,6 @@ async def stop_qemu_node_capture( await node.stop_capture(adapter_number) -@router.post( - "/{node_id}/adapters/{adapter_number}/ports/{port_number}/markers/start", - dependencies=[Depends(compute_authentication)] -) -async def start_qemu_node_marker( - *, - project_id: UUID, - adapter_number: int, - marker_data: schemas.MarkerCreate, - port_number: int = Path(..., ge=0, le=0), - node: QemuVM = Depends(dep_node) -) -> dict: - """ - Attach a traffic-insight ``mark`` filter to the QEMU node's uBridge bridge. - """ - - pcap_path = os.path.join( - node.project.markers_working_directory(), - f"{node.id}_{marker_data.link_id}_{marker_data.name}.pcap" - ) - await node.start_marker(adapter_number, marker_data.name, marker_data.bpf, pcap_path, marker_data.tag) - MarkerManager.instance().register( - str(project_id), node.id, marker_data.name, marker_data.link_id, marker_data.tag - ) - nio = node.get_nio(adapter_number) - if nio: - nio.markers[marker_data.name] = { - "bpf": marker_data.bpf, "tag": marker_data.tag, "link_id": marker_data.link_id - } - return {"pcap_file_path": str(pcap_path)} - - -@router.post( - "/{node_id}/adapters/{adapter_number}/ports/{port_number}/markers/stop", - status_code=status.HTTP_204_NO_CONTENT, - dependencies=[Depends(compute_authentication)] -) -async def stop_qemu_node_marker( - adapter_number: int, - marker_data: schemas.MarkerDelete, - port_number: int = Path(..., ge=0, le=0), - node: QemuVM = Depends(dep_node) -) -> None: - """ - Remove a traffic-insight ``mark`` filter from the QEMU node's uBridge bridge. - """ - - await node.stop_marker(adapter_number, marker_data.name) - MarkerManager.instance().unregister(node.id, marker_data.name) - nio = node.get_nio(adapter_number) - if nio: - nio.markers.pop(marker_data.name, None) - - @router.get( "/{node_id}/adapters/{adapter_number}/ports/{port_number}/capture/stream", dependencies=[Depends(compute_authentication)] diff --git a/gns3server/api/routes/compute/vpcs_nodes.py b/gns3server/api/routes/compute/vpcs_nodes.py index 74b349959..07c439972 100644 --- a/gns3server/api/routes/compute/vpcs_nodes.py +++ b/gns3server/api/routes/compute/vpcs_nodes.py @@ -29,8 +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 gns3server.compute.marker.marker_manager import MarkerManager - from .dependencies.authentication import compute_authentication, ws_compute_authentication responses = {404: {"model": schemas.ErrorMessage, "description": "Could not find project or VMware node"}} @@ -305,65 +303,6 @@ async def stop_vpcs_node_capture( await node.stop_capture(port_number) -@router.post( - "/{node_id}/adapters/{adapter_number}/ports/{port_number}/markers/start", - dependencies=[Depends(compute_authentication)] -) -async def start_vpcs_node_marker( - *, - project_id: UUID, - port_number: int, - marker_data: schemas.MarkerCreate, - adapter_number: int = Path(..., ge=0, le=0), - node: VPCSVM = Depends(dep_node) -) -> dict: - """ - Attach a traffic-insight ``mark`` filter to the VPCS node's uBridge bridge. - On BPF match uBridge emits a MARK signal and appends the packet to the pcap. - """ - - pcap_path = os.path.join( - node.project.markers_working_directory(), - f"{node.id}_{marker_data.link_id}_{marker_data.name}.pcap" - ) - await node.start_marker(port_number, marker_data.name, marker_data.bpf, pcap_path, marker_data.tag) - MarkerManager.instance().register( - str(project_id), node.id, marker_data.name, marker_data.link_id, marker_data.tag - ) - # Mirror the marker spec onto the NIO so it survives a node restart (the NIO - # persists across stop/start and _ubridge_apply_markers re-applies its markers). - nio = node.get_nio(port_number) - if nio: - nio.markers[marker_data.name] = { - "bpf": marker_data.bpf, - "tag": marker_data.tag, - "link_id": marker_data.link_id, - } - return {"pcap_file_path": pcap_path} - - -@router.post( - "/{node_id}/adapters/{adapter_number}/ports/{port_number}/markers/stop", - status_code=status.HTTP_204_NO_CONTENT, - dependencies=[Depends(compute_authentication)] -) -async def stop_vpcs_node_marker( - *, - port_number: int, - marker_data: schemas.MarkerDelete, - adapter_number: int = Path(..., ge=0, le=0), - node: VPCSVM = Depends(dep_node) -) -> None: - """ - Remove a traffic-insight ``mark`` filter from the VPCS node's uBridge bridge. - """ - - await node.stop_marker(port_number, marker_data.name) - MarkerManager.instance().unregister(node.id, marker_data.name) - nio = node.get_nio(port_number) - if nio: - nio.markers.pop(marker_data.name, None) - @router.get( "/{node_id}/adapters/{adapter_number}/ports/{port_number}/capture/stream", diff --git a/gns3server/compute/base_node.py b/gns3server/compute/base_node.py index 3618fea30..8ce6c6783 100644 --- a/gns3server/compute/base_node.py +++ b/gns3server/compute/base_node.py @@ -1101,19 +1101,6 @@ class BaseNode: # bad expression must surface instead of being silently dropped. await self._ubridge_send(cmd) - async def _ubridge_delete_marker_filter(self, bridge_name, name): - """ - Remove a `mark` filter from a uBridge bridge. - - uBridge closes and flushes the filter's pcap on delete; the file itself - persists on disk for later replay. - - :param bridge_name: uBridge bridge the filter is attached to - :param name: filter name previously passed to _ubridge_add_marker_filter - """ - - await self._ubridge_send(f"bridge delete_packet_filter {bridge_name} {name}") - async def _ubridge_apply_markers(self, bridge_name, nio): """ (Re-)apply every traffic-insight marker carried by *nio* to the uBridge @@ -1137,7 +1124,18 @@ class BaseNode: pcap_path = os.path.join( markers_dir, f"{self._id}_{link_id}_{name}.pcap" ) - await self._ubridge_add_marker_filter(bridge_name, name, bpf, pcap_path, tag) + try: + await self._ubridge_add_marker_filter(bridge_name, name, bpf, pcap_path, tag) + 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 ) diff --git a/gns3server/compute/docker/docker_vm.py b/gns3server/compute/docker/docker_vm.py index 5b787a39f..8c139cdfd 100644 --- a/gns3server/compute/docker/docker_vm.py +++ b/gns3server/compute/docker/docker_vm.py @@ -1440,44 +1440,6 @@ class DockerVM(BaseNode): ) ) - async def start_marker(self, adapter_number, name, bpf, pcap_path, tag=None): - """ - Attach a traffic-insight ``mark`` filter to this adapter's uBridge bridge. - On BPF match uBridge emits a MARK signal and appends the packet to the pcap. - - :param adapter_number: adapter number - :param name: stable filter name — pcap identity and echoed in MARK 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 - """ - - if self.status == "started" and self.ubridge: - adapter = f"bridge{adapter_number}" - await self._ubridge_add_marker_filter(adapter, name, bpf, pcap_path, tag) - log.info( - "Docker VM '{name}' [{id}]: starting marker '{marker}' on adapter {adapter_number}".format( - name=self.name, id=self.id, marker=name, adapter_number=adapter_number - ) - ) - - async def stop_marker(self, adapter_number, name): - """ - Remove a traffic-insight ``mark`` filter from this adapter's uBridge bridge. - - :param adapter_number: adapter number - :param name: filter name previously passed to start_marker - """ - - if self.status == "started" and self.ubridge: - adapter = f"bridge{adapter_number}" - await self._ubridge_delete_marker_filter(adapter, name) - log.info( - "Docker VM '{name}' [{id}]: stopping marker '{marker}' on adapter {adapter_number}".format( - name=self.name, id=self.id, marker=name, adapter_number=adapter_number - ) - ) - async def _get_log(self): """ Returns the log from the container diff --git a/gns3server/compute/qemu/qemu_vm.py b/gns3server/compute/qemu/qemu_vm.py index b659d9cb4..f91177111 100644 --- a/gns3server/compute/qemu/qemu_vm.py +++ b/gns3server/compute/qemu/qemu_vm.py @@ -1697,44 +1697,6 @@ class QemuVM(BaseNode): ) ) - async def start_marker(self, adapter_number, name, bpf, pcap_path, tag=None): - """ - Attach a traffic-insight ``mark`` filter to this adapter's uBridge bridge. - On BPF match uBridge emits a MARK signal and appends the packet to the pcap. - - :param adapter_number: adapter number - :param name: stable filter name — pcap identity and echoed in MARK 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 - """ - - if self.ubridge: - await self._ubridge_add_marker_filter( - f"QEMU-{self._id}-{adapter_number}", name, bpf, pcap_path, tag - ) - log.info( - "QEMU VM '{name}' [{id}]: starting marker '{marker}' on adapter {adapter_number}".format( - name=self.name, id=self.id, marker=name, adapter_number=adapter_number - ) - ) - - async def stop_marker(self, adapter_number, name): - """ - Remove a traffic-insight ``mark`` filter from this adapter's uBridge bridge. - - :param adapter_number: adapter number - :param name: filter name previously passed to start_marker - """ - - if self.ubridge: - await self._ubridge_delete_marker_filter(f"QEMU-{self._id}-{adapter_number}", name) - log.info( - "QEMU VM '{name}' [{id}]: stopping marker '{marker}' on adapter {adapter_number}".format( - name=self.name, id=self.id, marker=name, adapter_number=adapter_number - ) - ) - async def create_disk_image(self, disk_name, options): """ Create a Qemu disk diff --git a/gns3server/compute/vpcs/vpcs_vm.py b/gns3server/compute/vpcs/vpcs_vm.py index 0d3353a9a..b42c4095a 100644 --- a/gns3server/compute/vpcs/vpcs_vm.py +++ b/gns3server/compute/vpcs/vpcs_vm.py @@ -512,42 +512,6 @@ class VPCSVM(BaseNode): ) ) - async def start_marker(self, port_number, name, bpf, pcap_path, tag=None): - """ - Attach a traffic-insight ``mark`` filter to this node's uBridge bridge. - On BPF match uBridge emits a MARK signal and appends the packet to the pcap. - - :param port_number: port number (kept for API symmetry; VPCS has a single bridge) - :param name: stable filter name — pcap identity and echoed in MARK 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 - """ - - if self.ubridge: - await self._ubridge_add_marker_filter(f"VPCS-{self._id}", name, bpf, pcap_path, tag) - log.info( - "VPCS '{name}' [{id}]: starting marker '{marker}' on port {port_number}".format( - name=self.name, id=self.id, marker=name, port_number=port_number - ) - ) - - async def stop_marker(self, port_number, name): - """ - Remove a traffic-insight ``mark`` filter from this node's uBridge bridge. - - :param port_number: port number (kept for API symmetry) - :param name: filter name previously passed to start_marker - """ - - if self.ubridge: - await self._ubridge_delete_marker_filter(f"VPCS-{self._id}", name) - log.info( - "VPCS '{name}' [{id}]: stopping marker '{marker}' on port {port_number}".format( - name=self.name, id=self.id, marker=name, port_number=port_number - ) - ) - def _build_command(self): """ Command to start the VPCS process. diff --git a/gns3server/controller/project.py b/gns3server/controller/project.py index b7e15db3b..c0c0a262a 100644 --- a/gns3server/controller/project.py +++ b/gns3server/controller/project.py @@ -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 @@ -765,22 +766,31 @@ class Project: "Dropping invalid filters on link %s: %s", link_data.get("link_id"), e ) - # Restore traffic-insight markers. Each marker's capture side is resolved - # when the link is (re)created and the marker is applied to uBridge via - # _ubridge_apply_markers in add_ubridge_udp_connection. + # 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(): - try: - await link.start_marker( - name=name, - bpf=marker["bpf"], - tag=marker.get("tag"), - color=marker.get("color"), - ) - except (ControllerError, KeyError) as e: + 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: %s", - name, link_data.get("link_id"), e + "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: diff --git a/gns3server/controller/udp_link.py b/gns3server/controller/udp_link.py index a0b28b79d..ca8dface2 100644 --- a/gns3server/controller/udp_link.py +++ b/gns3server/controller/udp_link.py @@ -35,8 +35,6 @@ class UDPLink(Link): super().__init__(project, link_id=link_id) self._created = False self._link_data = [] - # Runtime-only Node references for marker commands (not serialized). - self._marker_capture_nodes = {} @property def debug_link_data(self): @@ -321,24 +319,16 @@ class UDPLink(Link): # explicitly deletes a marker via the REST API, and a marker is torn # down automatically only when its link is deleted. - def _capture_node_for_marker(self, name): - """Return the stored (node, adapter_number, port_number) for a marker's capture side.""" - return self._marker_capture_nodes.get(name) - - def _store_capture_node_for_marker(self, name, capture_side): - """Persist the capture-side identity (serializable refs) + runtime Node.""" - self._markers[name] = { - **self._markers.get(name, {}), - "capture_node_id": capture_side["node"].id, - "capture_adapter": capture_side["adapter_number"], - "capture_port": capture_side["port_number"], - } - self._marker_capture_nodes[name] = capture_side - async def start_marker(self, name, bpf, tag=None, color=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 @@ -349,28 +339,20 @@ class UDPLink(Link): if name in self._markers: raise ControllerError(f"Marker '{name}' already exists on link {self._id}") - # Pre-validate BPF on the controller side before reaching ubridge. 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() - # Record state + runtime capture-side ref unconditionally (so stop/update - # work and the marker is persisted), but only push to uBridge when the - # link is already live. During project load the link is not yet created - # (self._created is False); the marker then rides the NIO via create() - # and is applied once by _ubridge_apply_markers — mirroring exactly how - # update_filters guards its update() call. - self._store_capture_node_for_marker(name, marker_side) - self._markers[name].update({"bpf": bpf, "tag": tag, "enabled": True, "color": color}) + self._markers[name] = { + "bpf": bpf, + "tag": tag, + "enabled": True, + "color": color, + "capture_node_id": marker_side["node"].id, + } if self._created: - data = {"name": name, "bpf": bpf, "tag": tag, "link_id": self._id} - await marker_side["node"].post( - "/adapters/{adapter_number}/ports/{port_number}/markers/start".format( - adapter_number=marker_side["adapter_number"], port_number=marker_side["port_number"] - ), - data=data, - ) + await self.update() self._project.emit_notification("link.updated", self.asdict()) self._project.dump() @@ -378,30 +360,27 @@ class UDPLink(Link): """ 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 """ if name not in self._markers: raise ControllerNotFoundError(f"Marker '{name}' not found on link {self._id}") - capture_side = self._marker_capture_nodes.get(name) - if capture_side: - await capture_side["node"].post( - "/adapters/{adapter_number}/ports/{port_number}/markers/stop".format( - adapter_number=capture_side["adapter_number"], - port_number=capture_side["port_number"], - ), - data={"name": name}, - ) - self._markers.pop(name, None) - self._marker_capture_nodes.pop(name, None) + 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): """ - Update an existing marker. A BPF change requires delete+re-add so the - ubridge side flushes the pcap and the new filter takes effect. + 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) @@ -414,54 +393,19 @@ class UDPLink(Link): if not marker_info: raise ControllerNotFoundError(f"Marker '{name}' not found on link {self._id}") - new_bpf = bpf if bpf is not None else marker_info["bpf"] - new_tag = tag if tag is not None else marker_info.get("tag") - new_enabled = enabled if enabled is not None else marker_info.get("enabled", True) - new_color = color if color is not None else marker_info.get("color") - - if not new_enabled and marker_info.get("enabled", True): - # Toggle off: remove from ubridge but keep state. - await self.stop_marker(name) - self._markers[name] = { - **marker_info, "bpf": new_bpf, "tag": new_tag, - "enabled": False, "color": new_color, - } - self._project.emit_notification("link.updated", self.asdict()) - self._project.dump() - return - - capture_side = self._marker_capture_nodes.get(name) - if new_bpf != marker_info.get("bpf") or new_tag != marker_info.get("tag"): - # BPF or tag changed — re-validate, delete, re-add. - if new_bpf != marker_info.get("bpf"): - result = validate_bpf_syntax(new_bpf) - if not result.get("valid"): - raise ControllerError(f"Invalid BPF expression: {result.get('error', 'unknown error')}") - if capture_side: - # Delete old filter from ubridge. - await capture_side["node"].post( - "/adapters/{adapter_number}/ports/{port_number}/markers/stop".format( - adapter_number=capture_side["adapter_number"], - port_number=capture_side["port_number"], - ), - data={"name": name}, - ) - # Re-add with new params. - data = {"name": name, "bpf": new_bpf, "tag": new_tag, "link_id": self._id} - await capture_side["node"].post( - "/adapters/{adapter_number}/ports/{port_number}/markers/start".format( - adapter_number=capture_side["adapter_number"], - port_number=capture_side["port_number"], - ), - data=data, - ) - self._markers[name] = { - **marker_info, "bpf": new_bpf, "tag": new_tag, - "enabled": True, "color": new_color, - } - elif new_color != marker_info.get("color"): - # Color-only change: no ubridge round-trip, just update state. - self._markers[name] = {**marker_info, "color": new_color} + 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 self._created: + await self.update() self._project.emit_notification("link.updated", self.asdict()) self._project.dump() diff --git a/gns3server/schemas/__init__.py b/gns3server/schemas/__init__.py index 3c8d0ac63..f4adf0e76 100644 --- a/gns3server/schemas/__init__.py +++ b/gns3server/schemas/__init__.py @@ -20,7 +20,7 @@ from .common import ErrorMessage from .version import Version # Controller schemas -from .controller.links import LinkCreate, LinkUpdate, Link, UDPPortInfo, EthernetPortInfo, LinkCapture, MarkerCreate, MarkerDelete +from .controller.links import LinkCreate, LinkUpdate, Link, UDPPortInfo, EthernetPortInfo, LinkCapture, MarkerCreate 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 diff --git a/gns3server/schemas/controller/links.py b/gns3server/schemas/controller/links.py index b21afae04..a05c8187c 100644 --- a/gns3server/schemas/controller/links.py +++ b/gns3server/schemas/controller/links.py @@ -159,9 +159,3 @@ class MarkerCreate(BaseModel): ) -class MarkerDelete(BaseModel): - """ - Body for removing a traffic-insight marker from a link. - """ - - name: str From ab5b7444d48587574daa621af00f076c7e9f3c95 Mon Sep 17 00:00:00 2001 From: YueGuobin Date: Sun, 12 Jul 2026 23:11:09 +0800 Subject: [PATCH 10/31] feat(marker): add IOU node marker support via iol_bridge IOU uses iol_bridge (not bridge) with extra bay/unit positional arguments in add_packet_filter. Add _ubridge_apply_markers override that mirrors the existing _ubridge_apply_filters pattern, called at the same three sites: _networking, adapter_add_nio_binding, and adapter_update_nio_binding. --- gns3server/compute/iou/iou_vm.py | 56 ++++++++++++++++++++++++++++++++ 1 file changed, 56 insertions(+) diff --git a/gns3server/compute/iou/iou_vm.py b/gns3server/compute/iou/iou_vm.py index 506bff3d7..d36795af3 100644 --- a/gns3server/compute/iou/iou_vm.py +++ b/gns3server/compute/iou/iou_vm.py @@ -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,59 @@ 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}" + 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. From f2360f85fcf5c4ea795b05adb86c8462aee42ee3 Mon Sep 17 00:00:00 2001 From: YueGuobin Date: Mon, 13 Jul 2026 10:25:47 +0800 Subject: [PATCH 11/31] feat(marker): add project-level marker aggregation endpoint Add a read-only `markers` property on Project that flattens every link's markers into a single dict keyed by "{link_id}/{name}", each entry carrying the parent link_id and capture-side node_id. Expose it via GET /projects/{pid}/markers (Project.Audit) so the frontend can fetch all markers in one round-trip instead of enumerating links first. Also surface a marker count in project.stats(). --- gns3server/api/routes/controller/projects.py | 15 ++++++++++++ gns3server/controller/project.py | 25 ++++++++++++++++++++ 2 files changed, 40 insertions(+) diff --git a/gns3server/api/routes/controller/projects.py b/gns3server/api/routes/controller/projects.py index 333053e3b..39c225594 100644 --- a/gns3server/api/routes/controller/projects.py +++ b/gns3server/api/routes/controller/projects.py @@ -203,6 +203,21 @@ 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 + + @router.post( "/{project_id}/close", status_code=status.HTTP_204_NO_CONTENT, diff --git a/gns3server/controller/project.py b/gns3server/controller/project.py index c0c0a262a..1c446082a 100644 --- a/gns3server/controller/project.py +++ b/gns3server/controller/project.py @@ -898,6 +898,30 @@ 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 snapshots(self): """ @@ -1710,6 +1734,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): From a495b5ddf2fcd1e6c14e6209ccdeae89a5c4b722 Mon Sep 17 00:00:00 2001 From: YueGuobin Date: Mon, 13 Jul 2026 10:25:53 +0800 Subject: [PATCH 12/31] fix(marker): uniquify auto-generated marker names per link Auto-generating `marker-{link.id[:8]}` collided on the second anonymous marker on the same link (start_marker rejects duplicate names). Append a short uuid hex suffix so multiple anonymous markers coexist. --- gns3server/api/routes/controller/links.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/gns3server/api/routes/controller/links.py b/gns3server/api/routes/controller/links.py index ec5e4f58a..b48d490f1 100644 --- a/gns3server/api/routes/controller/links.py +++ b/gns3server/api/routes/controller/links.py @@ -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 @@ -454,7 +454,11 @@ async def create_marker( Required privilege: Link.Modify """ - name = marker_data.name or f"marker-{link.id[:8]}" + # 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). + name = marker_data.name or f"marker-{link.id[:8]}-{uuid4().hex[:4]}" await link.start_marker( name=name, bpf=marker_data.bpf, From 7e4600b5a12052236869938c4b7dcedb5349b717 Mon Sep 17 00:00:00 2001 From: YueGuobin Date: Mon, 13 Jul 2026 15:04:55 +0800 Subject: [PATCH 13/31] fix(marker): validate marker name and expose enabled via REST Add a name pattern constraint to MarkerCreate schema (alphanumeric + _.-) to prevent injection into uBridge commands, with a matching defense-in-depth check in _ubridge_add_marker_filter for hand-edited topology files. Also add the missing "enabled" field to MarkerCreate and pass it through the PUT route so the documented toggle actually works. --- gns3server/api/routes/controller/links.py | 1 + gns3server/compute/base_node.py | 5 +++++ gns3server/schemas/controller/links.py | 13 +++++++++++-- 3 files changed, 17 insertions(+), 2 deletions(-) diff --git a/gns3server/api/routes/controller/links.py b/gns3server/api/routes/controller/links.py index b48d490f1..12ef9eeff 100644 --- a/gns3server/api/routes/controller/links.py +++ b/gns3server/api/routes/controller/links.py @@ -506,6 +506,7 @@ async def update_marker( bpf=marker_data.bpf if marker_data.bpf else None, tag=marker_data.tag, color=marker_data.color, + enabled=marker_data.enabled, ) return link.markers.get(marker_name, {}) diff --git a/gns3server/compute/base_node.py b/gns3server/compute/base_node.py index 8ce6c6783..4b6deff10 100644 --- a/gns3server/compute/base_node.py +++ b/gns3server/compute/base_node.py @@ -1091,6 +1091,11 @@ class BaseNode: """ # mark [tag ] [pcap ] — 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. + _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 ) diff --git a/gns3server/schemas/controller/links.py b/gns3server/schemas/controller/links.py index a05c8187c..2deae4fb2 100644 --- a/gns3server/schemas/controller/links.py +++ b/gns3server/schemas/controller/links.py @@ -149,13 +149,22 @@ class MarkerCreate(BaseModel): absent) but always set when the controller forwards to the compute. """ - name: Optional[str] = None + 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'" + description="User-chosen hex color for this marker in the Web UI, e.g. '#ff5722'", + ) + enabled: Optional[bool] = Field( + None, + description="Whether the marker is active. Defaults to true on creation.", ) From f7fb43a4b9cae3a899d4685794cc7aa988120d0d Mon Sep 17 00:00:00 2001 From: YueGuobin Date: Mon, 13 Jul 2026 15:05:11 +0800 Subject: [PATCH 14/31] fix(marker): tolerate port conflict on marker listener startup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When the configured marker_listen_port (default 3070) is already in use, fall back to an OS-assigned port instead of crashing the server. If even port 0 fails, log the error and continue without marker signals — traffic insight degrades rather than taking down startup. --- gns3server/compute/marker/marker_manager.py | 28 ++++++++++++++++++--- 1 file changed, 25 insertions(+), 3 deletions(-) diff --git a/gns3server/compute/marker/marker_manager.py b/gns3server/compute/marker/marker_manager.py index 7a4168762..6ec128158 100644 --- a/gns3server/compute/marker/marker_manager.py +++ b/gns3server/compute/marker/marker_manager.py @@ -75,9 +75,31 @@ class MarkerManager: return loop = asyncio.get_running_loop() self._listener = MarkerListener(self) - self._transport, _ = await loop.create_datagram_endpoint( - lambda: self._listener, local_addr=(host, port) - ) + 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 From e4be98984cdb1f175a9f174f325e6f2c03637344 Mon Sep 17 00:00:00 2001 From: YueGuobin Date: Mon, 13 Jul 2026 15:05:40 +0800 Subject: [PATCH 15/31] fix(marker): enable marker support for docker nodes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Docker's _connect_nio and adapter_update_nio_binding applied packet filters but never called _ubridge_apply_markers, so markers silently did nothing on Docker links despite docker being in the allowlist. Add the missing calls (same pattern as the IOU fix). Also narrow _MARKER_CAPABLE_TYPES to the four types that actually implement marker support — vpcs, qemu, docker, iou — removing dynamips, virtualbox, vmware, and cloud which have no marker pathway and would silently fail when selected as the capture side. --- gns3server/compute/docker/docker_vm.py | 3 ++- gns3server/controller/udp_link.py | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/gns3server/compute/docker/docker_vm.py b/gns3server/compute/docker/docker_vm.py index 8c139cdfd..7315dad0d 100644 --- a/gns3server/compute/docker/docker_vm.py +++ b/gns3server/compute/docker/docker_vm.py @@ -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. diff --git a/gns3server/controller/udp_link.py b/gns3server/controller/udp_link.py index ca8dface2..da08c5f6b 100644 --- a/gns3server/controller/udp_link.py +++ b/gns3server/controller/udp_link.py @@ -26,7 +26,7 @@ from gns3server.utils.packet_filter_validation import validate_bpf_syntax, Filte # `mark` filter to). Mirrors _get_filter_node in link.py, minus "nat" # (which has no uBridge). _MARKER_CAPABLE_TYPES = frozenset({ - "vpcs", "qemu", "docker", "iou", "virtualbox", "vmware", "dynamips", "cloud", + "vpcs", "qemu", "docker", "iou", }) From d3527c00de2330aa09e189ef18a5e730350fc829 Mon Sep 17 00:00:00 2001 From: YueGuobin Date: Mon, 13 Jul 2026 15:12:08 +0800 Subject: [PATCH 16/31] fix(marker): extend marker support to dynamips and cloud nodes Dynamips needed its own NIO base class extended with a `_markers` attribute and `markers` property (mirroring the existing `filters` pattern), plus marker propagation from the controller-supplied NIO settings through NIOUDP.create/update to the common destination NIO that base_node._ubridge_apply_markers reads. Cloud simply needed the missing _ubridge_apply_markers calls after _ubridge_apply_filters in its two ubridge connection methods, same pattern as the earlier docker fix. Both types are now added back to _MARKER_CAPABLE_TYPES. --- gns3server/compute/builtin/nodes/cloud.py | 2 ++ gns3server/compute/dynamips/__init__.py | 1 + gns3server/compute/dynamips/nios/nio.py | 21 +++++++++++++++++++++ gns3server/compute/dynamips/nios/nio_udp.py | 2 ++ gns3server/controller/udp_link.py | 2 +- 5 files changed, 27 insertions(+), 1 deletion(-) diff --git a/gns3server/compute/builtin/nodes/cloud.py b/gns3server/compute/builtin/nodes/cloud.py index 50bf11084..d71669198 100644 --- a/gns3server/compute/builtin/nodes/cloud.py +++ b/gns3server/compute/builtin/nodes/cloud.py @@ -312,6 +312,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 +453,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): """ diff --git a/gns3server/compute/dynamips/__init__.py b/gns3server/compute/dynamips/__init__.py index 3d620b6a2..eeb4ed91f 100644 --- a/gns3server/compute/dynamips/__init__.py +++ b/gns3server/compute/dynamips/__init__.py @@ -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"] diff --git a/gns3server/compute/dynamips/nios/nio.py b/gns3server/compute/dynamips/nios/nio.py index 2872b89eb..5c5c9ec6d 100644 --- a/gns3server/compute/dynamips/nios/nio.py +++ b/gns3server/compute/dynamips/nios/nio.py @@ -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): """ diff --git a/gns3server/compute/dynamips/nios/nio_udp.py b/gns3server/compute/dynamips/nios/nio_udp.py index 47faacc43..d849a37bf 100644 --- a/gns3server/compute/dynamips/nios/nio_udp.py +++ b/gns3server/compute/dynamips/nios/nio_udp.py @@ -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): diff --git a/gns3server/controller/udp_link.py b/gns3server/controller/udp_link.py index da08c5f6b..c52ec650f 100644 --- a/gns3server/controller/udp_link.py +++ b/gns3server/controller/udp_link.py @@ -26,7 +26,7 @@ from gns3server.utils.packet_filter_validation import validate_bpf_syntax, Filte # `mark` filter to). Mirrors _get_filter_node in link.py, minus "nat" # (which has no uBridge). _MARKER_CAPABLE_TYPES = frozenset({ - "vpcs", "qemu", "docker", "iou", + "vpcs", "qemu", "docker", "iou", "dynamips", "cloud", }) From 369badc6c27e1135a3e6bad8d0ea54995b74376a Mon Sep 17 00:00:00 2001 From: YueGuobin Date: Mon, 13 Jul 2026 15:23:27 +0800 Subject: [PATCH 17/31] fix(marker): reserve "global" name prefix for future marker inheritance --- gns3server/compute/base_node.py | 2 +- gns3server/schemas/controller/links.py | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/gns3server/compute/base_node.py b/gns3server/compute/base_node.py index 4b6deff10..02d4ce0e3 100644 --- a/gns3server/compute/base_node.py +++ b/gns3server/compute/base_node.py @@ -1093,7 +1093,7 @@ class BaseNode: # mark [tag ] [pcap ] — 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. - _MARKER_NAME_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_.-]*$") + _MARKER_NAME_RE = re.compile(r"^(?i)(?!global)[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( diff --git a/gns3server/schemas/controller/links.py b/gns3server/schemas/controller/links.py index 2deae4fb2..a3733c9ea 100644 --- a/gns3server/schemas/controller/links.py +++ b/gns3server/schemas/controller/links.py @@ -151,9 +151,9 @@ class MarkerCreate(BaseModel): name: Optional[str] = Field( None, - pattern=r"^[A-Za-z0-9][A-Za-z0-9_.-]*$", + pattern=r"^(?i)(?!global)[A-Za-z0-9][A-Za-z0-9_.-]*$", max_length=128, - description="Unique marker name on the link. Auto-generated when absent.", + description='Unique marker name on the link. Auto-generated when absent. Names starting with "global" are reserved.', ) bpf: str tag: Optional[int] = None From 31991fe359ef38d7a75c57bfdb44c605ab8c133b Mon Sep 17 00:00:00 2001 From: YueGuobin Date: Mon, 13 Jul 2026 15:37:07 +0800 Subject: [PATCH 18/31] feat(marker): add project-level marker definition inheritance MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Project-level marker definitions fan out to every link (existing and new). A definition is stored once on Project._marker_definitions; when applied to a link the marker is named "global-{def_name}" — the "global" prefix was pre-reserved in the schema, so inherited and per-link markers can never collide, nor will their registry keys. Key behavior: - POST /projects/{pid}/marker-definitions → fan out to all existing links - PUT /projects/{pid}/marker-definitions/{name} → sync all inherited copies - DELETE → remove every inherited copy from every link - New links auto-inherit all active defs (hook in UDPLink.create) - Per-link DELETE/PUT of a "global-*" marker is rejected (409) - Inherited markers are NOT persisted in the topology; they are re-created from _marker_definitions on project load - Compute side is untouched — the marker reaches uBridge via the existing start_marker→update→NIO→ubridge pipeline Files: - controller/project.py — _marker_definitions + CRUD + fanout + topology load - controller/link.py — Link.inherit_marker() + asdict() filter - controller/udp_link.py — guards on stop/update + create() inheritance hook - controller/topology.py — persist marker_definitions in project topology - schemas/controller/links.py — MarkerDefinitionCreate schema - api/routes/controller/projects.py — REST endpoints (marker-definitions) --- gns3server/api/routes/controller/projects.py | 94 ++++++++++++++ gns3server/controller/link.py | 30 ++++- gns3server/controller/project.py | 121 +++++++++++++++++++ gns3server/controller/topology.py | 1 + gns3server/controller/udp_link.py | 26 +++- gns3server/schemas/controller/links.py | 23 ++++ 6 files changed, 291 insertions(+), 4 deletions(-) diff --git a/gns3server/api/routes/controller/projects.py b/gns3server/api/routes/controller/projects.py index 39c225594..6bfb84ba6 100644 --- a/gns3server/api/routes/controller/projects.py +++ b/gns3server/api/routes/controller/projects.py @@ -218,6 +218,100 @@ def get_project_markers(project: Project = Depends(dep_project)) -> dict: 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 + """ + + 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, + ) + 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, + ) + 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, diff --git a/gns3server/controller/link.py b/gns3server/controller/link.py index 0e959cef8..ac1e17c94 100644 --- a/gns3server/controller/link.py +++ b/gns3server/controller/link.py @@ -107,6 +107,32 @@ class Link: """ 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"), + 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): """ @@ -600,7 +626,7 @@ class Link: "nodes": res, "link_id": self._id, "filters": self._filters, - "markers": self._markers, + "markers": self._persist_markers(), "link_style": self._link_style, "suspend": self._suspended, "show_filters_icon": getattr(self, '_show_filters_icon', True), @@ -615,7 +641,7 @@ class Link: "capture_compute_id": self.capture_compute_id, "link_type": self._link_type, "filters": self._filters, - "markers": self._markers, + "markers": self._persist_markers(), "suspend": self._suspended, "link_style": self._link_style, "wireshark": self._wireshark, diff --git a/gns3server/controller/project.py b/gns3server/controller/project.py index 1c446082a..3eeeda20a 100644 --- a/gns3server/controller/project.py +++ b/gns3server/controller/project.py @@ -212,6 +212,7 @@ class Project: self._allocated_node_names = set() self._nodes = {} self._links = {} + self._marker_definitions = {} # name → {bpf, tag, color} self._drawings = {} self._snapshots = {} self._computes = [] @@ -922,6 +923,119 @@ class Project: } return result + @property + def marker_definitions(self): + """ + :returns: dict of project-level marker definitions (name → {bpf, tag, color}) + """ + return self._marker_definitions + + async def create_marker_definition(self, name, bpf, tag=None, color=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} + 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): + """ + 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 + + # 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") + ) + 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) + 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): """ @@ -1296,6 +1410,7 @@ class Project: "auto_start", "auto_close", "auto_open", + "marker_definitions", "scene_height", "scene_width", "zoom", @@ -1378,6 +1493,11 @@ class Project: for drawing_data in topology.get("drawings", []): await self.add_drawing(dump=False, **drawing_data) + # After every link is loaded, apply project-level marker definitions + # so inherited markers are present from the start. + for link in list(self._links.values()): + await self.apply_defs_to_new_link(link) + self.dump() # We catch all error to be able to roll back the .gns3 to the previous state except Exception as e: @@ -1759,6 +1879,7 @@ class Project: "supplier": self._supplier, "variables": self._variables, "created_by": self._created_by, + "marker_definitions": self._marker_definitions, } def __repr__(self): diff --git a/gns3server/controller/topology.py b/gns3server/controller/topology.py index 150283c39..a71b88dd6 100644 --- a/gns3server/controller/topology.py +++ b/gns3server/controller/topology.py @@ -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, diff --git a/gns3server/controller/udp_link.py b/gns3server/controller/udp_link.py index c52ec650f..dedb92b8e 100644 --- a/gns3server/controller/udp_link.py +++ b/gns3server/controller/udp_link.py @@ -145,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. + self._project.apply_defs_to_new_link(self) async def update(self): """ @@ -319,7 +322,7 @@ class UDPLink(Link): # 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): + async def start_marker(self, name, bpf, tag=None, color=None, inherited_from=None): """ Attach a traffic-insight marker to this link. @@ -334,6 +337,8 @@ class UDPLink(Link): :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 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: @@ -344,13 +349,16 @@ class UDPLink(Link): raise ControllerError(f"Invalid BPF expression: {result.get('error', 'unknown error')}") marker_side = self._choose_marker_side() - self._markers[name] = { + marker_entry = { "bpf": bpf, "tag": tag, "enabled": True, "color": color, "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()) @@ -370,6 +378,13 @@ class UDPLink(Link): if name not in self._markers: raise ControllerNotFoundError(f"Marker '{name}' not found on link {self._id}") + if self._markers[name].get("inherited_from"): + 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() @@ -393,6 +408,13 @@ class UDPLink(Link): if not marker_info: raise ControllerNotFoundError(f"Marker '{name}' not found on link {self._id}") + if marker_info.get("inherited_from"): + 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"): diff --git a/gns3server/schemas/controller/links.py b/gns3server/schemas/controller/links.py index a3733c9ea..0f5b10235 100644 --- a/gns3server/schemas/controller/links.py +++ b/gns3server/schemas/controller/links.py @@ -168,3 +168,26 @@ class MarkerCreate(BaseModel): ) +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"^(?i)(?!global)[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'", + ) + + From 686f96b748f649ab3be9461182b9a074b48cdb25 Mon Sep 17 00:00:00 2001 From: YueGuobin Date: Mon, 13 Jul 2026 15:40:25 +0800 Subject: [PATCH 19/31] fix(marker): replace pydantic lookahead pattern with field_validator pydantic_core (Rust engine) does not support regex look-around. Keep the character-class pattern for basic safety and enforce the "global" prefix reservation via a field_validator instead. --- gns3server/schemas/controller/links.py | 22 ++++++++++++++++++---- 1 file changed, 18 insertions(+), 4 deletions(-) diff --git a/gns3server/schemas/controller/links.py b/gns3server/schemas/controller/links.py index 0f5b10235..21de66add 100644 --- a/gns3server/schemas/controller/links.py +++ b/gns3server/schemas/controller/links.py @@ -14,7 +14,7 @@ # You should have received a copy of the GNU General Public License # along with this program. If not, see . -from pydantic import BaseModel, Field +from pydantic import BaseModel, Field, field_validator from typing import List, Optional, Tuple from enum import Enum from uuid import UUID, uuid4 @@ -151,9 +151,9 @@ class MarkerCreate(BaseModel): name: Optional[str] = Field( None, - pattern=r"^(?i)(?!global)[A-Za-z0-9][A-Za-z0-9_.-]*$", + pattern=r"^[A-Za-z0-9][A-Za-z0-9_.-]*$", max_length=128, - description='Unique marker name on the link. Auto-generated when absent. Names starting with "global" are reserved.', + description='Unique marker name on the link. Auto-generated when absent.', ) bpf: str tag: Optional[int] = None @@ -167,6 +167,13 @@ class MarkerCreate(BaseModel): description="Whether the marker is active. Defaults to true on creation.", ) + @field_validator("name") + @classmethod + def _check_name_not_reserved(cls, v): + if v is not None and v.lower().startswith("global"): + raise ValueError('Names starting with "global" are reserved') + return v + class MarkerDefinitionCreate(BaseModel): """ @@ -179,7 +186,7 @@ class MarkerDefinitionCreate(BaseModel): name: Optional[str] = Field( None, - pattern=r"^(?i)(?!global)[A-Za-z0-9][A-Za-z0-9_.-]*$", + pattern=r"^[A-Za-z0-9][A-Za-z0-9_.-]*$", max_length=128, description="Unique definition name. Auto-generated when absent.", ) @@ -190,4 +197,11 @@ class MarkerDefinitionCreate(BaseModel): description="User-chosen hex color for the marker in the Web UI, e.g. '#ff5722'", ) + @field_validator("name") + @classmethod + def _check_name_not_reserved(cls, v): + if v is not None and v.lower().startswith("global"): + raise ValueError('Names starting with "global" are reserved') + return v + From 84845f85040c5e1090b6895d9d4b134094bcf887 Mon Sep 17 00:00:00 2001 From: YueGuobin Date: Mon, 13 Jul 2026 15:41:58 +0800 Subject: [PATCH 20/31] fix(marker): export MarkerDefinitionCreate from schemas package --- gns3server/schemas/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gns3server/schemas/__init__.py b/gns3server/schemas/__init__.py index f4adf0e76..7519639bc 100644 --- a/gns3server/schemas/__init__.py +++ b/gns3server/schemas/__init__.py @@ -20,7 +20,7 @@ from .common import ErrorMessage from .version import Version # Controller schemas -from .controller.links import LinkCreate, LinkUpdate, Link, UDPPortInfo, EthernetPortInfo, LinkCapture, MarkerCreate +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 From 179f072c33bd583119d0bbe2859bb74c93e1087f Mon Sep 17 00:00:00 2001 From: YueGuobin Date: Mon, 13 Jul 2026 22:47:41 +0800 Subject: [PATCH 21/31] fix(marker): fix inheritance hook, topology load, update sync, and asdict MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bugs found via end-to-end testing of project-level marker definitions: 1. New links didn't inherit — apply_defs_to_new_link is async but was called without await in UDPLink.create(). 2. Project load crashed — load_project passed marker_definitions to Project.__init__. Now popped in load_project and restored separately in Project.open() (it backs a read-only property). 3. PUT on a definition didn't sync to links — update_marker's guard rejected even the project-layer sync call. Added an `inherited` bypass flag used by update_marker_definition. 4. _ubridge_add_marker_filter raised re.PatternError — the name regex used (?i)(?!global) look-around, invalid in Python's re module. Dropped the prefix check there: "global-*" names are legitimate at the uBridge boundary (inherited definitions); forbidden only at the user-facing schema. 5. GET /links hid inherited markers — asdict()'s runtime branch used _persist_markers() (which filters inherited markers). Restored self._markers for the runtime branch; only the topology_dump branch filters (inherited markers are rebuilt from definitions on load). 6. Duplicate "already exists" warnings on project open — open() fanned out definitions to all links, but UDPLink.create() had already done so via its inheritance hook. Removed the redundant fan-out in open(). --- gns3server/compute/base_node.py | 5 ++++- gns3server/controller/__init__.py | 3 +++ gns3server/controller/link.py | 2 +- gns3server/controller/project.py | 16 ++++++++++------ gns3server/controller/udp_link.py | 8 +++++--- 5 files changed, 23 insertions(+), 11 deletions(-) diff --git a/gns3server/compute/base_node.py b/gns3server/compute/base_node.py index 02d4ce0e3..a4a2ce365 100644 --- a/gns3server/compute/base_node.py +++ b/gns3server/compute/base_node.py @@ -1093,7 +1093,10 @@ class BaseNode: # mark [tag ] [pcap ] — 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. - _MARKER_NAME_RE = re.compile(r"^(?i)(?!global)[A-Za-z0-9][A-Za-z0-9_.-]*$") + # 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( diff --git a/gns3server/controller/__init__.py b/gns3server/controller/__init__.py index 9cfe2bcd7..edf654ec3 100644 --- a/gns3server/controller/__init__.py +++ b/gns3server/controller/__init__.py @@ -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"]] diff --git a/gns3server/controller/link.py b/gns3server/controller/link.py index ac1e17c94..8ac755ba6 100644 --- a/gns3server/controller/link.py +++ b/gns3server/controller/link.py @@ -641,7 +641,7 @@ class Link: "capture_compute_id": self.capture_compute_id, "link_type": self._link_type, "filters": self._filters, - "markers": self._persist_markers(), + "markers": self._markers, "suspend": self._suspended, "link_style": self._link_style, "wireshark": self._wireshark, diff --git a/gns3server/controller/project.py b/gns3server/controller/project.py index 3eeeda20a..c8c9be93f 100644 --- a/gns3server/controller/project.py +++ b/gns3server/controller/project.py @@ -970,7 +970,7 @@ class Project: 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") + marker_name, bpf=d["bpf"], tag=d.get("tag"), color=d.get("color"), inherited=True ) self.dump() self.emit_notification("project.updated", self.asdict()) @@ -1410,7 +1410,6 @@ class Project: "auto_start", "auto_close", "auto_open", - "marker_definitions", "scene_height", "scene_width", "zoom", @@ -1427,6 +1426,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") @@ -1493,10 +1498,9 @@ class Project: for drawing_data in topology.get("drawings", []): await self.add_drawing(dump=False, **drawing_data) - # After every link is loaded, apply project-level marker definitions - # so inherited markers are present from the start. - for link in list(self._links.values()): - await self.apply_defs_to_new_link(link) + # 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 diff --git a/gns3server/controller/udp_link.py b/gns3server/controller/udp_link.py index dedb92b8e..58f357960 100644 --- a/gns3server/controller/udp_link.py +++ b/gns3server/controller/udp_link.py @@ -147,7 +147,7 @@ class UDPLink(Link): self._created = True # New links automatically inherit every active project-level marker # definition so the user doesn't have to reconfigure. - self._project.apply_defs_to_new_link(self) + await self._project.apply_defs_to_new_link(self) async def update(self): """ @@ -391,7 +391,7 @@ class UDPLink(Link): 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): + async def update_marker(self, name, bpf=None, tag=None, enabled=None, color=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 @@ -402,13 +402,15 @@ class UDPLink(Link): :param tag: new tag id (None = keep existing) :param enabled: toggle (None = keep existing) :param color: new hex color (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"): + 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']}'. " From 98bcd5eddd2711540824280ebbfa62c9fec5de79 Mon Sep 17 00:00:00 2001 From: YueGuobin Date: Mon, 13 Jul 2026 23:32:08 +0800 Subject: [PATCH 22/31] feat(marker): add highlight_duration render hint Per-marker UI hint (milliseconds, ge=1) for how long the Web UI keeps a marker highlighted after a match. Mirrors color: stored on the link, persisted in the topology, inherited via project-level definitions, and never sent to uBridge. Omitted = null = frontend uses its own default. The default is intentionally NOT set in the schema: MarkerCreate also backs PUT updates, so a schema default would make every partial update silently reset the value. None means "not provided" (keep existing on update / frontend decides on create). --- gns3server/api/routes/controller/links.py | 2 ++ gns3server/api/routes/controller/projects.py | 2 ++ gns3server/controller/link.py | 1 + gns3server/controller/project.py | 15 +++++++++------ gns3server/controller/udp_link.py | 10 ++++++++-- gns3server/schemas/controller/links.py | 18 ++++++++++++++++++ 6 files changed, 40 insertions(+), 8 deletions(-) diff --git a/gns3server/api/routes/controller/links.py b/gns3server/api/routes/controller/links.py index 12ef9eeff..5a51905e0 100644 --- a/gns3server/api/routes/controller/links.py +++ b/gns3server/api/routes/controller/links.py @@ -464,6 +464,7 @@ async def create_marker( bpf=marker_data.bpf, tag=marker_data.tag, color=marker_data.color, + highlight_duration=marker_data.highlight_duration, ) return link.markers.get(name, {}) @@ -507,6 +508,7 @@ async def update_marker( tag=marker_data.tag, color=marker_data.color, enabled=marker_data.enabled, + highlight_duration=marker_data.highlight_duration, ) return link.markers.get(marker_name, {}) diff --git a/gns3server/api/routes/controller/projects.py b/gns3server/api/routes/controller/projects.py index 6bfb84ba6..103c7d310 100644 --- a/gns3server/api/routes/controller/projects.py +++ b/gns3server/api/routes/controller/projects.py @@ -266,6 +266,7 @@ async def create_marker_definition( bpf=def_data.bpf, tag=def_data.tag, color=def_data.color, + highlight_duration=def_data.highlight_duration, ) return project.marker_definitions.get(name, {}) @@ -290,6 +291,7 @@ async def update_marker_definition( 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, {}) diff --git a/gns3server/controller/link.py b/gns3server/controller/link.py index 8ac755ba6..008deda4d 100644 --- a/gns3server/controller/link.py +++ b/gns3server/controller/link.py @@ -122,6 +122,7 @@ class Link: 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, ) diff --git a/gns3server/controller/project.py b/gns3server/controller/project.py index c8c9be93f..2d1671f31 100644 --- a/gns3server/controller/project.py +++ b/gns3server/controller/project.py @@ -212,7 +212,7 @@ class Project: self._allocated_node_names = set() self._nodes = {} self._links = {} - self._marker_definitions = {} # name → {bpf, tag, color} + self._marker_definitions = {} # name → {bpf, tag, color, highlight_duration} self._drawings = {} self._snapshots = {} self._computes = [] @@ -926,11 +926,11 @@ class Project: @property def marker_definitions(self): """ - :returns: dict of project-level marker definitions (name → {bpf, tag, color}) + :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): + 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 @@ -942,12 +942,12 @@ class Project: f"Marker definition '{name}' already exists in this project" ) - self._marker_definitions[name] = {"bpf": bpf, "tag": tag, "color": color} + 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): + 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. """ @@ -964,13 +964,16 @@ class Project: 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"), inherited=True + 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()) diff --git a/gns3server/controller/udp_link.py b/gns3server/controller/udp_link.py index 58f357960..de797b77f 100644 --- a/gns3server/controller/udp_link.py +++ b/gns3server/controller/udp_link.py @@ -322,7 +322,7 @@ class UDPLink(Link): # 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, inherited_from=None): + 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. @@ -337,6 +337,8 @@ class UDPLink(Link): :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 """ @@ -354,6 +356,7 @@ class UDPLink(Link): "tag": tag, "enabled": True, "color": color, + "highlight_duration": highlight_duration, "capture_node_id": marker_side["node"].id, } if inherited_from: @@ -391,7 +394,7 @@ class UDPLink(Link): 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, inherited=False): + 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 @@ -402,6 +405,7 @@ class UDPLink(Link): :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) """ @@ -428,6 +432,8 @@ class UDPLink(Link): 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() diff --git a/gns3server/schemas/controller/links.py b/gns3server/schemas/controller/links.py index 21de66add..82bfc48b9 100644 --- a/gns3server/schemas/controller/links.py +++ b/gns3server/schemas/controller/links.py @@ -162,6 +162,15 @@ class MarkerCreate(BaseModel): 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.", @@ -196,6 +205,15 @@ class MarkerDefinitionCreate(BaseModel): 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." + ), + ) @field_validator("name") @classmethod From cdc27c37a7d17aceb3eab58c8a620daf78511a4b Mon Sep 17 00:00:00 2001 From: YueGuobin Date: Mon, 13 Jul 2026 23:32:42 +0800 Subject: [PATCH 23/31] fix(marker): clean inherited markers on def delete delete_marker_definition removed the def but left the inherited global-* copies on every link: it called stop_marker(), which rejects inherited markers (409), and the ControllerError was swallowed as a warning. The orphaned copies were in-memory only (_persist_markers filters inherited markers) so a restart hid the symptom, but during a running session they were undeletable via either the per-link or project API. Add the same inherited=True bypass that update_marker already has, and pass it from the def-delete fan-out so the copies are removed for real. --- gns3server/controller/project.py | 2 +- gns3server/controller/udp_link.py | 6 ++++-- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/gns3server/controller/project.py b/gns3server/controller/project.py index 2d1671f31..9ada6506a 100644 --- a/gns3server/controller/project.py +++ b/gns3server/controller/project.py @@ -994,7 +994,7 @@ class Project: 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) + await link.stop_marker(marker_name, inherited=True) except ControllerError: # A missing compute or broken link shouldn't block the delete. log.warning( diff --git a/gns3server/controller/udp_link.py b/gns3server/controller/udp_link.py index de797b77f..609c17495 100644 --- a/gns3server/controller/udp_link.py +++ b/gns3server/controller/udp_link.py @@ -367,7 +367,7 @@ class UDPLink(Link): self._project.emit_notification("link.updated", self.asdict()) self._project.dump() - async def stop_marker(self, name): + async def stop_marker(self, name, inherited=False): """ Remove a traffic-insight marker from this link. @@ -376,12 +376,14 @@ class UDPLink(Link): 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"): + 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']}'. " From b21780605e557ba6350563d4275e5531baffa152 Mon Sep 17 00:00:00 2001 From: YueGuobin Date: Tue, 14 Jul 2026 00:10:51 +0800 Subject: [PATCH 24/31] fix(marker): reject reserved global-prefix names only on create MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The global-prefix reservation belongs on the create path, where it keeps user-chosen names disjoint from inherited global-{name} markers. It was implemented as a field_validator on MarkerCreate / MarkerDefinitionCreate, which also back the PUT update bodies — so updating an inherited marker while echoing its name in the body tripped the validator with a generic 422 before the controller could return the actionable 409 ("inherited, use the marker-definitions API"). Drop the schema validators and enforce the prefix in the two create routes (ControllerError -> 409). PUT no longer validates the body name (ignored anyway — the target is the {name} path param), so editing an inherited marker now reaches the controller's inheritance guard and returns the clear 409. The name-format regex stays on the schema (still 422). --- gns3server/api/routes/controller/links.py | 2 ++ gns3server/api/routes/controller/projects.py | 2 ++ gns3server/schemas/controller/links.py | 16 +--------------- 3 files changed, 5 insertions(+), 15 deletions(-) diff --git a/gns3server/api/routes/controller/links.py b/gns3server/api/routes/controller/links.py index 5a51905e0..9e33b7ba3 100644 --- a/gns3server/api/routes/controller/links.py +++ b/gns3server/api/routes/controller/links.py @@ -458,6 +458,8 @@ async def create_marker( # 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, diff --git a/gns3server/api/routes/controller/projects.py b/gns3server/api/routes/controller/projects.py index 103c7d310..5e6f1c6de 100644 --- a/gns3server/api/routes/controller/projects.py +++ b/gns3server/api/routes/controller/projects.py @@ -260,6 +260,8 @@ async def create_marker_definition( 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, diff --git a/gns3server/schemas/controller/links.py b/gns3server/schemas/controller/links.py index 82bfc48b9..23a0cd18a 100644 --- a/gns3server/schemas/controller/links.py +++ b/gns3server/schemas/controller/links.py @@ -14,7 +14,7 @@ # You should have received a copy of the GNU General Public License # along with this program. If not, see . -from pydantic import BaseModel, Field, field_validator +from pydantic import BaseModel, Field from typing import List, Optional, Tuple from enum import Enum from uuid import UUID, uuid4 @@ -176,13 +176,6 @@ class MarkerCreate(BaseModel): description="Whether the marker is active. Defaults to true on creation.", ) - @field_validator("name") - @classmethod - def _check_name_not_reserved(cls, v): - if v is not None and v.lower().startswith("global"): - raise ValueError('Names starting with "global" are reserved') - return v - class MarkerDefinitionCreate(BaseModel): """ @@ -215,11 +208,4 @@ class MarkerDefinitionCreate(BaseModel): ), ) - @field_validator("name") - @classmethod - def _check_name_not_reserved(cls, v): - if v is not None and v.lower().startswith("global"): - raise ValueError('Names starting with "global" are reserved') - return v - From cbb4854d3deb93b2f7ace9964181a00190b0ceb1 Mon Sep 17 00:00:00 2001 From: YueGuobin Date: Tue, 14 Jul 2026 00:11:17 +0800 Subject: [PATCH 25/31] docs(marker): add traffic-insight feature documentation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Document the marker feature following the gns3-documentation standard: overview, Mermaid architecture and business-process diagrams, API endpoint tables, request/response and field reference, error responses, and notes covering the key design points — immutable name (rename = delete + create), global-prefix reservation, read-only inherited markers, render hints (color / highlight_duration), supported node types, and persistence. Indexed under Features in docs/README.md. --- docs/README.md | 3 + docs/features/marker-traffic-insight.md | 221 ++++++++++++++++++++++++ 2 files changed, 224 insertions(+) create mode 100644 docs/features/marker-traffic-insight.md diff --git a/docs/README.md b/docs/README.md index 7b40b951c..bc2d414aa 100644 --- a/docs/README.md +++ b/docs/README.md @@ -72,6 +72,9 @@ Unified error response format across all GNS3 API endpoints. Documents HTTP stat ### Web Wireshark (`features/web-wireshark-business-process.md`) Web-based packet capture analysis using Docker + xpra HTML5 client. Zero-install Wireshark experience directly in the browser, integrated with GNS3 topologies. +### Marker (Traffic Insight) (`features/marker-traffic-insight.md`) +Real-time traffic insight via per-link BPF markers and project-level inherited definitions. A marker taps a link in uBridge, emitting match notifications and pcap capture on BPF hit; definitions fan out to every capable link automatically. + --- ## GNS3 AI Copilot (`gns3-copilot/`) diff --git a/docs/features/marker-traffic-insight.md b/docs/features/marker-traffic-insight.md new file mode 100644 index 000000000..86f893469 --- /dev/null +++ b/docs/features/marker-traffic-insight.md @@ -0,0 +1,221 @@ + + +> This documentation is organized by AI with reference to actual code. AI can make mistakes — please verify against the source code when in doubt. + +# Marker (Traffic Insight) + +## Overview + +A **marker** is a passive traffic-insight tap attached to a link. It runs a libpcap BPF +expression inside uBridge; on every match uBridge emits a real-time `MARK` signal and +appends the matching packet to a per-marker pcap file. Markers exist at two layers that +coexist on the same link: **per-link private markers** and **project-level definitions** +that are inherited by every capable link. + +## Architecture + +```mermaid +graph TB + UI["Web UI"] + + subgraph Controller["Controller"] + DEF["Project definitions
(inheritance templates)"] + LNK["Per-link markers"] + end + + Compute["Compute Node"] + UB["uBridge
mark filter"] + PCAP[("pcap file")] + LSTN["Marker listener
(UDP, per compute)"] + + UI -->|"REST + notifications ws"| Controller + DEF -.->|"fan-out: global-{name}"| LNK + LNK -->|"node.post /markers"| Compute + Compute --> UB + UB -->|"BPF match"| PCAP + UB -->|"UDP MARK signal"| LSTN + LSTN -->|"marker.match"| UI +``` + +Inheritance is a controller-only fan-out: a definition CRUD loops over links and reuses the +existing per-link marker operations, so the compute side sees an ordinary marker and is +unchanged. Each compute process runs one UDP listener serving every uBridge on that host; +the `node` field in each signal identifies the source. + +## Business Process + +```mermaid +sequenceDiagram + participant UI as Web UI + participant C as Controller + participant L as Capable Link + participant N as Compute / uBridge + + UI->>C: POST /marker-definitions {name, bpf, ...} + C->>C: store definition + loop every capable link + C->>L: start_marker("global-{name}") + L->>N: install mark filter (BPF + pcap) + end + C-->>UI: 201 + link_ids + + Note over N: later: a packet matches the BPF + N->>N: emit MARK signal + append pcap + N-->>UI: marker.match notification (per-project ws) +``` + +Updating a definition syncs `bpf / tag / color / highlight_duration` to every inherited +copy; deleting a definition removes every inherited copy. A newly created link inherits all +existing definitions automatically. + +## API Endpoints + +All endpoints require a JWT bearer token (`POST /v3/access/users/authenticate`). The +`Auth` column lists the required privilege. + +### Per-link markers + +| Method | Path | Description | Auth | +|--------|------|-------------|------| +| GET | `/v3/projects/{pid}/links/{lid}/markers` | List markers on a link | Link.Audit | +| POST | `/v3/projects/{pid}/links/{lid}/markers` | Attach a marker | Link.Modify | +| PUT | `/v3/projects/{pid}/links/{lid}/markers/{name}` | Update a marker | Link.Modify | +| DELETE | `/v3/projects/{pid}/links/{lid}/markers/{name}` | Remove a marker | Link.Modify | + +### Project-level definitions + +| Method | Path | Description | Auth | +|--------|------|-------------|------| +| GET | `/v3/projects/{pid}/marker-definitions` | List definitions + bound `link_ids` | Project.Audit | +| POST | `/v3/projects/{pid}/marker-definitions` | Create definition (fans out to every link) | Project.Modify | +| PUT | `/v3/projects/{pid}/marker-definitions/{name}` | Update definition (syncs all copies) | Project.Modify | +| DELETE | `/v3/projects/{pid}/marker-definitions/{name}` | Delete definition (clears all copies) | Project.Modify | + +### Aggregation + +| Method | Path | Description | Auth | +|--------|------|-------------|------| +| GET | `/v3/projects/{pid}/markers` | All markers across links, flat | Project.Audit | + +The link object returned by `GET /v3/projects/{pid}/links[/{lid}]` also carries a `markers` +field (including inherited markers), so the Web UI can render a link's markers without an +extra request. + +## Request / Response + +**Marker create body** (`MarkerCreate`, shared by per-link POST and PUT): + +```json +{ + "name": "icmp", + "bpf": "icmp", + "tag": 1, + "color": "#ff5722", + "highlight_duration": 800, + "enabled": true +} +``` + +**Definition create body** (`MarkerDefinitionCreate`, shared by POST and PUT): + +```json +{ + "name": "arp", + "bpf": "arp", + "tag": 5, + "color": "#ff5722", + "highlight_duration": 1200 +} +``` + +**Marker entry** (returned by GET/POST/PUT, and the value of each link's `markers[name]`): + +```json +{ + "bpf": "icmp", + "tag": 1, + "enabled": true, + "color": "#ff5722", + "highlight_duration": 800, + "capture_node_id": "a37e2235-e21f-46c9-a2ab-ba0f8c5465e6", + "inherited_from": null +} +``` + +**Definition GET response** (adds `link_ids`): + +```json +{ + "arp": { + "bpf": "arp", + "tag": 5, + "color": null, + "highlight_duration": 1200, + "link_ids": ["656ed826-...", "6bd9d156-..."] + } +} +``` + +## Field Reference + +### Marker entry + +| Field | Type | Description | +|-------|------|-------------| +| `bpf` | string | libpcap BPF expression (required) | +| `tag` | int \| null | Correlation id echoed in `MARK` signals | +| `enabled` | bool | Whether the marker is active | +| `color` | string \| null | Hex color render hint, e.g. `#ff5722` | +| `highlight_duration` | int \| null | UI highlight duration in ms after a match; `null` = UI default | +| `capture_node_id` | string | Server-chosen node whose uBridge hosts the marker | +| `inherited_from` | string | Source definition name — present on inherited markers only | + +### Definition + +| Field | Type | Description | +|-------|------|-------------| +| `bpf` | string | libpcap BPF expression (required) | +| `tag` | int \| null | Correlation id | +| `color` | string \| null | Hex color render hint | +| `highlight_duration` | int \| null | UI highlight duration in ms; `null` = UI default | +| `link_ids` | string[] | Links currently carrying an inherited copy (GET only) | + +### Notifications + +| Event | Payload | Delivered to | +|-------|---------|--------------| +| `link.updated` | Link object (its `markers` field is the source of truth) | Project notification ws | +| `marker.match` | `project_id`, `node_id`, `link_id`, `filter`, `tag`, `ts`, `len` | Project notification ws only | + +## Error Responses + +| Status | Description | +|--------|-------------| +| 401 | Not authenticated | +| 404 | Link / marker / definition not found | +| 409 | Per-link edit or delete of an inherited marker; reserved (`global`) name or duplicate name on create | +| 422 | Validation failure (name format, `highlight_duration < 1`, missing `bpf`) | + +## Notes + +- **Marker name is immutable.** It is the identifier across the controller, the uBridge + filter, the pcap filename, and `MARK` signal routing — so rename is a delete + recreate, + not a field update. PUT ignores the body `name`; the `{name}` path parameter identifies + the target, and only `bpf / tag / color / enabled / highlight_duration` are changeable. +- **`global` prefix reserved.** User-chosen names may not start with `global`; inherited + markers are stored as `global-{definition_name}` so the two namespaces cannot collide. + Omitting `name` on create yields an auto-generated, prefix-free name. +- **Inherited markers are read-only per-link.** PUT/DELETE on an inherited marker returns + 409 — edit them through the definitions API. +- **Render hints are not enforced.** `color` and `highlight_duration` (milliseconds, `>= 1`) + are stored on the link and never sent to uBridge; `null` lets the UI apply its own + default. A partial PUT (e.g. changing only `bpf`) leaves them untouched. +- **Supported node types.** A marker needs a uBridge bridge: `vpcs`, `qemu`, `docker`, + `iou`, `dynamips`, `cloud` (one capable endpoint suffices). Types without a uBridge are + silently skipped by the inheritance fan-out. +- **Persistence.** Definitions and private markers persist in the topology; inherited + markers are re-created from definitions on project load, so reopening a project restores + the same configuration and stale inherited copies cannot survive on disk. From 213738f0a58a68260b1a40ed25a9b98dff245d7c Mon Sep 17 00:00:00 2001 From: YueGuobin Date: Tue, 14 Jul 2026 00:42:58 +0800 Subject: [PATCH 26/31] test(marker): add controller + API route tests for traffic-insight 19 controller-layer tests covering start/stop/update_marker (storage, inheritance guards + bypasses, partial-update preservation of render hints), project-def CRUD (fan-out, sync, delete-cleanup regression), apply_defs_to_new_link, persist_markers/asdict, and aggregation. 15 API-route tests covering per-link create/update/delete (201/200/204), global-prefix rejection on create (409 regression), bad-format rejection (422), PUT/DELETE-on-inherited guard (409 regression), project-def CRUD endpoints, and the aggregation view. All 34 tests pass when run as part of the full suite. --- tests/api/routes/controller/test_markers.py | 256 +++++++++++++++ tests/controller/test_marker.py | 346 ++++++++++++++++++++ 2 files changed, 602 insertions(+) create mode 100644 tests/api/routes/controller/test_markers.py create mode 100644 tests/controller/test_marker.py diff --git a/tests/api/routes/controller/test_markers.py b/tests/api/routes/controller/test_markers.py new file mode 100644 index 000000000..d9fc27b3b --- /dev/null +++ b/tests/api/routes/controller/test_markers.py @@ -0,0 +1,256 @@ +# -*- coding: utf-8 -*- +# +# Copyright (C) 2025 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-route tests for the traffic-insight marker endpoints: per-link markers, +project-level definitions, and the project-wide aggregation view. +""" + +import pytest +from fastapi import FastAPI, status +from httpx import AsyncClient + +from tests.utils import asyncio_patch + +from gns3server.controller.project import Project +from gns3server.controller.udp_link import UDPLink + +pytestmark = pytest.mark.asyncio + + +def _inherited(link, name="arp"): + """Inject an inherited marker so the controller's inheritance guard can fire.""" + link._markers[f"global-{name}"] = { + "bpf": name, "tag": None, "enabled": True, "color": None, + "highlight_duration": None, "capture_node_id": "node-id", + "inherited_from": name, + } + + +class TestMarkerRoutes: + + # ----------------------------------------------------------------------- + # Per-link markers + # ----------------------------------------------------------------------- + + async def test_create_marker(self, app: FastAPI, client: AsyncClient, project: Project) -> None: + + link = UDPLink(project) + project._links = {link.id: link} + + with asyncio_patch("gns3server.controller.udp_link.UDPLink.start_marker") as mock: + response = await client.post( + app.url_path_for("create_marker", project_id=project.id, link_id=link.id), + json={"bpf": "icmp", "tag": 3, "color": "#ff5722", "highlight_duration": 800}, + ) + + assert response.status_code == status.HTTP_201_CREATED + mock.assert_called_once() + _, kwargs = mock.call_args + assert kwargs["bpf"] == "icmp" + assert kwargs["tag"] == 3 + assert kwargs["color"] == "#ff5722" + assert kwargs["highlight_duration"] == 800 + assert kwargs["name"].startswith("marker-") + + async def test_create_marker_with_explicit_name(self, app: FastAPI, client: AsyncClient, project: Project) -> None: + + link = UDPLink(project) + project._links = {link.id: link} + + with asyncio_patch("gns3server.controller.udp_link.UDPLink.start_marker") as mock: + response = await client.post( + app.url_path_for("create_marker", project_id=project.id, link_id=link.id), + json={"name": "web", "bpf": "tcp port 80"}, + ) + assert response.status_code == status.HTTP_201_CREATED + _, kwargs = mock.call_args + assert kwargs["name"] == "web" + + async def test_create_marker_global_prefix_rejected(self, app: FastAPI, client: AsyncClient, project: Project) -> None: + + link = UDPLink(project) + project._links = {link.id: link} + + with asyncio_patch("gns3server.controller.udp_link.UDPLink.start_marker") as mock: + response = await client.post( + app.url_path_for("create_marker", project_id=project.id, link_id=link.id), + json={"name": "global-x", "bpf": "icmp"}, + ) + assert response.status_code == status.HTTP_409_CONFLICT + assert not mock.called # rejected before reaching the controller + + async def test_create_marker_bad_format_rejected(self, app: FastAPI, client: AsyncClient, project: Project) -> None: + + link = UDPLink(project) + project._links = {link.id: link} + + response = await client.post( + app.url_path_for("create_marker", project_id=project.id, link_id=link.id), + json={"name": "bad name!", "bpf": "icmp"}, + ) + assert response.status_code == status.HTTP_422_UNPROCESSABLE_ENTITY + + async def test_get_markers(self, app: FastAPI, client: AsyncClient, project: Project) -> None: + + link = UDPLink(project) + link._markers["web"] = {"bpf": "tcp port 80", "tag": None, "enabled": True, + "color": None, "highlight_duration": 800, "capture_node_id": "n1"} + project._links = {link.id: link} + + response = await client.get( + app.url_path_for("get_markers", project_id=project.id, link_id=link.id) + ) + assert response.status_code == status.HTTP_200_OK + assert response.json()["web"]["highlight_duration"] == 800 + + async def test_update_marker(self, app: FastAPI, client: AsyncClient, project: Project) -> None: + + link = UDPLink(project) + project._links = {link.id: link} + + with asyncio_patch("gns3server.controller.udp_link.UDPLink.update_marker") as mock: + response = await client.put( + app.url_path_for("update_marker", project_id=project.id, link_id=link.id, marker_name="web"), + json={"bpf": "udp port 53", "highlight_duration": 1500}, + ) + assert response.status_code == status.HTTP_200_OK + _, kwargs = mock.call_args + assert kwargs["bpf"] == "udp port 53" + assert kwargs["highlight_duration"] == 1500 + + async def test_update_inherited_marker_rejected(self, app: FastAPI, client: AsyncClient, project: Project) -> None: + + link = UDPLink(project) + _inherited(link, "arp") + project._links = {link.id: link} + + response = await client.put( + app.url_path_for("update_marker", project_id=project.id, link_id=link.id, marker_name="global-arp"), + json={"name": "global-arp", "bpf": "arp"}, + ) + assert response.status_code == status.HTTP_409_CONFLICT + assert "inherited" in response.json()["message"] + + async def test_delete_marker(self, app: FastAPI, client: AsyncClient, project: Project) -> None: + + link = UDPLink(project) + project._links = {link.id: link} + + with asyncio_patch("gns3server.controller.udp_link.UDPLink.stop_marker") as mock: + response = await client.delete( + app.url_path_for("delete_marker", project_id=project.id, link_id=link.id, marker_name="web") + ) + assert response.status_code == status.HTTP_204_NO_CONTENT + mock.assert_called_once_with("web") + + async def test_delete_inherited_marker_rejected(self, app: FastAPI, client: AsyncClient, project: Project) -> None: + + link = UDPLink(project) + _inherited(link, "arp") + project._links = {link.id: link} + + response = await client.delete( + app.url_path_for("delete_marker", project_id=project.id, link_id=link.id, marker_name="global-arp") + ) + assert response.status_code == status.HTTP_409_CONFLICT + + # ----------------------------------------------------------------------- + # Project-level marker definitions + # ----------------------------------------------------------------------- + + async def test_create_marker_definition(self, app: FastAPI, client: AsyncClient, project: Project) -> None: + + with asyncio_patch("gns3server.controller.project.Project.create_marker_definition") as mock: + response = await client.post( + app.url_path_for("create_marker_definition", project_id=project.id), + json={"name": "arp", "bpf": "arp", "highlight_duration": 1200}, + ) + assert response.status_code == status.HTTP_201_CREATED + _, kwargs = mock.call_args + assert kwargs["name"] == "arp" + assert kwargs["bpf"] == "arp" + assert kwargs["highlight_duration"] == 1200 + + async def test_create_marker_definition_global_prefix_rejected(self, app: FastAPI, client: AsyncClient, project: Project) -> None: + + with asyncio_patch("gns3server.controller.project.Project.create_marker_definition") as mock: + response = await client.post( + app.url_path_for("create_marker_definition", project_id=project.id), + json={"name": "global-x", "bpf": "arp"}, + ) + assert response.status_code == status.HTTP_409_CONFLICT + assert not mock.called + + async def test_update_marker_definition(self, app: FastAPI, client: AsyncClient, project: Project) -> None: + + with asyncio_patch("gns3server.controller.project.Project.update_marker_definition") as mock: + response = await client.put( + app.url_path_for("update_marker_definition", project_id=project.id, def_name="arp"), + json={"bpf": "arp or rarp", "highlight_duration": 900}, + ) + assert response.status_code == status.HTTP_200_OK + _, kwargs = mock.call_args + assert kwargs["bpf"] == "arp or rarp" + assert kwargs["highlight_duration"] == 900 + + async def test_delete_marker_definition(self, app: FastAPI, client: AsyncClient, project: Project) -> None: + + with asyncio_patch("gns3server.controller.project.Project.delete_marker_definition") as mock: + response = await client.delete( + app.url_path_for("delete_marker_definition", project_id=project.id, def_name="arp") + ) + assert response.status_code == status.HTTP_204_NO_CONTENT + mock.assert_called_once_with("arp") + + async def test_get_marker_definitions(self, app: FastAPI, client: AsyncClient, project: Project) -> None: + + project._marker_definitions = { + "arp": {"bpf": "arp", "tag": 5, "color": None, "highlight_duration": 1200}, + } + project._links = {} + + response = await client.get( + app.url_path_for("get_marker_definitions", project_id=project.id) + ) + assert response.status_code == status.HTTP_200_OK + body = response.json() + assert body["arp"]["bpf"] == "arp" + assert body["arp"]["highlight_duration"] == 1200 + assert body["arp"]["link_ids"] == [] + + # ----------------------------------------------------------------------- + # Aggregation + # ----------------------------------------------------------------------- + + async def test_get_project_markers(self, app: FastAPI, client: AsyncClient, project: Project) -> None: + + link = UDPLink(project) + link._markers["icmp"] = {"bpf": "icmp", "tag": 1, "enabled": True, "color": "#ff5722", + "highlight_duration": 800, "capture_node_id": "node-1"} + project._links = {link.id: link} + + response = await client.get( + app.url_path_for("get_project_markers", project_id=project.id) + ) + assert response.status_code == status.HTTP_200_OK + body = response.json() + key = f"{link.id}/icmp" + assert key in body + assert body[key]["highlight_duration"] == 800 + assert body[key]["link_id"] == link.id + assert body[key]["node_id"] == "node-1" diff --git a/tests/controller/test_marker.py b/tests/controller/test_marker.py new file mode 100644 index 000000000..7dc0ebe13 --- /dev/null +++ b/tests/controller/test_marker.py @@ -0,0 +1,346 @@ +#!/usr/bin/env python +# +# Copyright (C) 2025 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 . + +""" +Controller-layer tests for the traffic-insight marker feature: + +* UDPLink.start_marker / stop_marker / update_marker — storage, guards, + inheritance bypass, and partial-update preservation of render hints. +* Project.create/update/delete_marker_definition — fan-out, sync, cleanup. +* Project.apply_defs_to_new_link and the markers aggregation property. +""" + +import pytest +from unittest.mock import MagicMock, patch + +from tests.utils import AsyncioMagicMock + +from gns3server.controller.udp_link import UDPLink +from gns3server.controller.ports.ethernet_port import EthernetPort +from gns3server.controller.node import Node +from gns3server.controller.controller_error import ControllerError, ControllerNotFoundError + + +def _valid_bpf(): + """Bypass tcpdump-based BPF validation so tests don't depend on tcpdump.""" + return patch( + "gns3server.controller.udp_link.validate_bpf_syntax", + return_value={"valid": True, "error": None}, + ) + + +async def _make_link(project): + """Build a created UDPLink between two VPCS nodes on a mocked compute.""" + + compute = MagicMock() + compute.id = "local" + compute.host = "example.com" + + node1 = Node(project, compute, "n1", node_type="vpcs") + node1._ports = [EthernetPort("E0", 0, 0, 0)] + node2 = Node(project, compute, "n2", node_type="vpcs") + node2._ports = [EthernetPort("E0", 0, 0, 1)] + + async def subnet(_other): + return ("192.168.1.1", "192.168.1.2") + + async def udp_cb(path, data={}, **kwargs): + response = MagicMock() + response.json = {"udp_port": 1234} + return response + + compute.get_ip_on_same_subnet.side_effect = subnet + compute.post.side_effect = udp_cb + # start_marker / update_marker push via node.put -> compute.put; make it awaitable. + compute.put = AsyncioMagicMock() + compute.delete = AsyncioMagicMock() + + link = UDPLink(project) + await link.add_node(node1, 0, 0) + await link.add_node(node2, 0, 1) + # Register with the project so definition fan-out (which iterates _links) reaches it. + project._links[link.id] = link + return link + + +# --------------------------------------------------------------------------- +# UDPLink.start_marker +# --------------------------------------------------------------------------- + +@pytest.mark.asyncio +async def test_start_marker_stores_entry(project): + + with _valid_bpf(): + link = await _make_link(project) + await link.start_marker("icmp", "icmp", tag=7, color="#ff5722", highlight_duration=800) + + entry = link.markers["icmp"] + assert entry["bpf"] == "icmp" + assert entry["tag"] == 7 + assert entry["color"] == "#ff5722" + assert entry["highlight_duration"] == 800 + assert entry["enabled"] is True + assert entry["capture_node_id"] in {n["node"].id for n in link._nodes} + assert "inherited_from" not in entry + + +@pytest.mark.asyncio +async def test_start_marker_rejects_duplicate(project): + + with _valid_bpf(): + link = await _make_link(project) + await link.start_marker("icmp", "icmp") + with pytest.raises(ControllerError): + await link.start_marker("icmp", "tcp") + + +@pytest.mark.asyncio +async def test_start_marker_rejects_invalid_bpf(project): + + link = await _make_link(project) + with patch("gns3server.controller.udp_link.validate_bpf_syntax", + return_value={"valid": False, "error": "bad expression"}): + with pytest.raises(ControllerError): + await link.start_marker("bad", "not a real bpf") + + +# --------------------------------------------------------------------------- +# UDPLink.stop_marker +# --------------------------------------------------------------------------- + +@pytest.mark.asyncio +async def test_stop_marker_removes(project): + + with _valid_bpf(): + link = await _make_link(project) + await link.start_marker("icmp", "icmp") + assert "icmp" in link.markers + await link.stop_marker("icmp") + assert "icmp" not in link.markers + + +@pytest.mark.asyncio +async def test_stop_marker_rejects_inherited(project): + + with _valid_bpf(): + link = await _make_link(project) + await link.inherit_marker("arp", {"bpf": "arp"}) + # Per-link delete of an inherited marker must be refused (use the def API). + with pytest.raises(ControllerError): + await link.stop_marker("global-arp") + assert "global-arp" in link.markers # still present + + +@pytest.mark.asyncio +async def test_stop_marker_inherited_bypass(project): + """The def-delete path passes inherited=True to remove inherited copies.""" + + with _valid_bpf(): + link = await _make_link(project) + await link.inherit_marker("arp", {"bpf": "arp"}) + await link.stop_marker("global-arp", inherited=True) + assert "global-arp" not in link.markers + + +@pytest.mark.asyncio +async def test_stop_marker_unknown_raises(project): + + link = await _make_link(project) + with pytest.raises(ControllerNotFoundError): + await link.stop_marker("nope") + + +# --------------------------------------------------------------------------- +# UDPLink.update_marker +# --------------------------------------------------------------------------- + +@pytest.mark.asyncio +async def test_update_marker_preserves_render_hints(project): + """A partial update (bpf only) must not reset color/highlight_duration/tag.""" + + with _valid_bpf(): + link = await _make_link(project) + await link.start_marker("m", "icmp", tag=1, color="#ff5722", highlight_duration=800) + await link.update_marker("m", bpf="tcp port 80") + + entry = link.markers["m"] + assert entry["bpf"] == "tcp port 80" + assert entry["color"] == "#ff5722" # preserved + assert entry["highlight_duration"] == 800 # preserved + assert entry["tag"] == 1 # preserved + + +@pytest.mark.asyncio +async def test_update_marker_changes_fields(project): + + with _valid_bpf(): + link = await _make_link(project) + await link.start_marker("m", "icmp", highlight_duration=800) + await link.update_marker("m", highlight_duration=1500, enabled=False, tag=9) + + entry = link.markers["m"] + assert entry["highlight_duration"] == 1500 + assert entry["enabled"] is False + assert entry["tag"] == 9 + + +@pytest.mark.asyncio +async def test_update_marker_rejects_inherited(project): + + with _valid_bpf(): + link = await _make_link(project) + await link.inherit_marker("arp", {"bpf": "arp"}) + with pytest.raises(ControllerError): + await link.update_marker("global-arp", bpf="tcp") + + +@pytest.mark.asyncio +async def test_update_marker_inherited_bypass(project): + """The def-sync path passes inherited=True to update inherited copies.""" + + with _valid_bpf(): + link = await _make_link(project) + await link.inherit_marker("arp", {"bpf": "arp", "highlight_duration": 500}) + await link.update_marker("global-arp", highlight_duration=1200, inherited=True) + assert link.markers["global-arp"]["highlight_duration"] == 1200 + + +# --------------------------------------------------------------------------- +# Link.inherit_marker + persistence +# --------------------------------------------------------------------------- + +@pytest.mark.asyncio +async def test_inherit_marker_creates_global_copy(project): + + with _valid_bpf(): + link = await _make_link(project) + await link.inherit_marker("arp", {"bpf": "arp", "tag": 3, "color": "#111", "highlight_duration": 400}) + + entry = link.markers["global-arp"] + assert entry["bpf"] == "arp" + assert entry["tag"] == 3 + assert entry["color"] == "#111" + assert entry["highlight_duration"] == 400 + assert entry["inherited_from"] == "arp" + + +@pytest.mark.asyncio +async def test_persist_markers_excludes_inherited(project): + """Inherited markers are re-created from definitions on load, never persisted.""" + + with _valid_bpf(): + link = await _make_link(project) + await link.start_marker("private", "icmp", highlight_duration=800) + await link.inherit_marker("arp", {"bpf": "arp"}) + + persisted = link._persist_markers() + assert set(persisted.keys()) == {"private"} + assert "global-arp" not in persisted + + +@pytest.mark.asyncio +async def test_asdict_markers_runtime_vs_dump(project): + """Runtime asdict exposes all markers; topology dump drops inherited ones.""" + + with _valid_bpf(): + link = await _make_link(project) + await link.start_marker("private", "icmp") + await link.inherit_marker("arp", {"bpf": "arp"}) + + runtime = link.asdict() + assert set(runtime["markers"].keys()) == {"private", "global-arp"} + dumped = link.asdict(topology_dump=True) + assert set(dumped["markers"].keys()) == {"private"} + + +# --------------------------------------------------------------------------- +# Project-level marker definitions +# --------------------------------------------------------------------------- + +@pytest.mark.asyncio +async def test_create_marker_definition_fans_out(project): + + with _valid_bpf(): + link1 = await _make_link(project) + link2 = await _make_link(project) + await project.create_marker_definition("arp", "arp", tag=5, highlight_duration=1200) + + for link in (link1, link2): + entry = link.markers["global-arp"] + assert entry["inherited_from"] == "arp" + assert entry["bpf"] == "arp" + assert entry["highlight_duration"] == 1200 + assert project.marker_definitions["arp"]["highlight_duration"] == 1200 + + +@pytest.mark.asyncio +async def test_update_marker_definition_syncs(project): + + with _valid_bpf(): + link1 = await _make_link(project) + link2 = await _make_link(project) + await project.create_marker_definition("arp", "arp", highlight_duration=500) + await project.update_marker_definition("arp", highlight_duration=1500, bpf="arp or rarp") + + for link in (link1, link2): + assert link.markers["global-arp"]["highlight_duration"] == 1500 + assert link.markers["global-arp"]["bpf"] == "arp or rarp" + assert project.marker_definitions["arp"]["highlight_duration"] == 1500 + + +@pytest.mark.asyncio +async def test_delete_marker_definition_clears(project): + """Regression: deleting a def must remove inherited copies from every link.""" + + with _valid_bpf(): + link1 = await _make_link(project) + link2 = await _make_link(project) + await project.create_marker_definition("arp", "arp") + assert "global-arp" in link1.markers + await project.delete_marker_definition("arp") + + assert "global-arp" not in link1.markers + assert "global-arp" not in link2.markers + assert "arp" not in project.marker_definitions + + +@pytest.mark.asyncio +async def test_apply_defs_to_new_link(project): + """A link created after a definition exists inherits it automatically.""" + + with _valid_bpf(): + await project.create_marker_definition("arp", "arp") + new_link = await _make_link(project) + + assert "global-arp" in new_link.markers + assert new_link.markers["global-arp"]["inherited_from"] == "arp" + + +@pytest.mark.asyncio +async def test_markers_aggregation(project): + + with _valid_bpf(): + link = await _make_link(project) + await link.start_marker("icmp", "icmp", highlight_duration=800) + + agg = project.markers + key = f"{link.id}/icmp" + assert key in agg + assert agg[key]["bpf"] == "icmp" + assert agg[key]["highlight_duration"] == 800 + assert agg[key]["link_id"] == link.id + assert agg[key]["node_id"] == agg[key]["capture_node_id"] From a732d8d52183552d732e2a629e2e806b626833d1 Mon Sep 17 00:00:00 2001 From: YueGuobin Date: Tue, 14 Jul 2026 00:49:51 +0800 Subject: [PATCH 27/31] test(marker): include marker_definitions in project-asdict expectations project.asdict() and project_to_topology() gained a marker_definitions key. Two tests that assert exact dict equality needed the new field. --- tests/controller/test_project.py | 1 + tests/controller/test_topology.py | 1 + 2 files changed, 2 insertions(+) diff --git a/tests/controller/test_project.py b/tests/controller/test_project.py index 24f9314ce..b0202c01f 100644 --- a/tests/controller/test_project.py +++ b/tests/controller/test_project.py @@ -82,6 +82,7 @@ async def test_json(): "drawing_grid_size": 25, "supplier": None, "variables": None, + "marker_definitions": {}, "created_by": None } diff --git a/tests/controller/test_topology.py b/tests/controller/test_topology.py index 85492596c..bc87c1ee3 100644 --- a/tests/controller/test_topology.py +++ b/tests/controller/test_topology.py @@ -60,6 +60,7 @@ async def test_project_to_topology_empty(tmpdir): "supplier": None, "variables": None, "version": __version__, + "marker_definitions": {}, "created_by": None } From 27356da62d0a9c5cc6c18ee577f84ed961bb4f9a Mon Sep 17 00:00:00 2001 From: YueGuobin Date: Tue, 14 Jul 2026 23:51:52 +0800 Subject: [PATCH 28/31] fix(marker): set nio.markers on iou/dynamips/cloud nio update The PUT /nio (update) endpoints for iou, dynamips and cloud copied nio.filters from the request body but never copied nio.markers. So adding a marker (e.g. a project-level global definition) to an already-created link of these node types left nio.markers empty: no mark filter was installed on uBridge and no marker.match signal was emitted. vpcs/qemu/docker already set it; mirror them (getattr for the Union NIO types). --- gns3server/api/routes/compute/cloud_nodes.py | 2 ++ gns3server/api/routes/compute/dynamips_nodes.py | 1 + gns3server/api/routes/compute/iou_nodes.py | 2 ++ 3 files changed, 5 insertions(+) diff --git a/gns3server/api/routes/compute/cloud_nodes.py b/gns3server/api/routes/compute/cloud_nodes.py index 8453ba0d8..42e691235 100644 --- a/gns3server/api/routes/compute/cloud_nodes.py +++ b/gns3server/api/routes/compute/cloud_nodes.py @@ -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() diff --git a/gns3server/api/routes/compute/dynamips_nodes.py b/gns3server/api/routes/compute/dynamips_nodes.py index 08eebb0d7..70d2b10c6 100644 --- a/gns3server/api/routes/compute/dynamips_nodes.py +++ b/gns3server/api/routes/compute/dynamips_nodes.py @@ -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() diff --git a/gns3server/api/routes/compute/iou_nodes.py b/gns3server/api/routes/compute/iou_nodes.py index 5be4fdd23..99544043d 100644 --- a/gns3server/api/routes/compute/iou_nodes.py +++ b/gns3server/api/routes/compute/iou_nodes.py @@ -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() From 794bfef45012df4ffac0a5f435a10318a60ecfeb Mon Sep 17 00:00:00 2001 From: YueGuobin Date: Tue, 14 Jul 2026 23:52:21 +0800 Subject: [PATCH 29/31] fix(marker): per-link attribution via link field in MARK signals A uBridge MARK signal carries only node= and filter= (no bridge/link field), so when one node is the capture side for several links that share a marker name (always the case for global-{name} definitions on a multi-interface node) the signals were indistinguishable and the (node, filter) registry collapsed them to a single link. The mark filter is now stamped with its link id (mark ... link ); uBridge echoes it verbatim (link=) and the listener uses the signal's link= as the authoritative link_id of the marker.match event, falling back to the registry only for legacy signals without it. base_node and iou apply paths pass link_id; covered by two new listener tests. --- gns3server/compute/base_node.py | 9 ++++++-- gns3server/compute/iou/iou_vm.py | 5 +++++ gns3server/compute/marker/marker_listener.py | 9 +++++++- tests/compute/marker/test_marker_manager.py | 22 ++++++++++++++++++++ 4 files changed, 42 insertions(+), 3 deletions(-) diff --git a/gns3server/compute/base_node.py b/gns3server/compute/base_node.py index a4a2ce365..24d7813ae 100644 --- a/gns3server/compute/base_node.py +++ b/gns3server/compute/base_node.py @@ -1073,7 +1073,7 @@ class BaseNode: ) i += 1 - async def _ubridge_add_marker_filter(self, bridge_name, name, bpf, pcap_path, tag=None): + 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. @@ -1104,6 +1104,11 @@ class BaseNode: ) 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. @@ -1133,7 +1138,7 @@ class BaseNode: markers_dir, f"{self._id}_{link_id}_{name}.pcap" ) try: - await self._ubridge_add_marker_filter(bridge_name, name, bpf, pcap_path, tag) + 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 diff --git a/gns3server/compute/iou/iou_vm.py b/gns3server/compute/iou/iou_vm.py index d36795af3..11bffb69a 100644 --- a/gns3server/compute/iou/iou_vm.py +++ b/gns3server/compute/iou/iou_vm.py @@ -1137,6 +1137,11 @@ class IOUVM(BaseNode): ) 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) diff --git a/gns3server/compute/marker/marker_listener.py b/gns3server/compute/marker/marker_listener.py index d8bfa3303..f22f59bff 100644 --- a/gns3server/compute/marker/marker_listener.py +++ b/gns3server/compute/marker/marker_listener.py @@ -79,6 +79,7 @@ class MarkerListener(asyncio.DatagramProtocol): 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") @@ -89,10 +90,16 @@ class MarkerListener(asyncio.DatagramProtocol): ) 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": link_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, diff --git a/tests/compute/marker/test_marker_manager.py b/tests/compute/marker/test_marker_manager.py index dc8e826ff..4a2796002 100644 --- a/tests/compute/marker/test_marker_manager.py +++ b/tests/compute/marker/test_marker_manager.py @@ -161,6 +161,28 @@ class TestMarkerListener: lis.datagram_received(b"MARK 2.0 node=n filter=f tag=- len=20\n", None) assert fmgr.events[0][1]["tag"] == 42 + def test_link_in_signal_overrides_registry_link(self): + # Per-link attribution (contract §3.2/§3.3): the signal's `link=` is + # authoritative and must disambiguate links sharing a node+filter — + # e.g. several links on one IOU node under the same global marker name. + fmgr = FakeMarkerManager() + fmgr.register("p", "n", "f", "registry-link", tag=1) + lis = MarkerListener(fmgr) + lis.connection_made(None) + lis.datagram_received( + b"MARK 3.0 node=n filter=f link=signal-link tag=1 len=42\n", None + ) + assert fmgr.events[0][1]["link_id"] == "signal-link" + + def test_link_dash_falls_back_to_registry_link(self): + # Legacy signals that carry no link fall back to the registry's link_id. + fmgr = FakeMarkerManager() + fmgr.register("p", "n", "f", "registry-link", tag=1) + lis = MarkerListener(fmgr) + lis.connection_made(None) + lis.datagram_received(b"MARK 3.0 node=n filter=f link=- tag=1 len=42\n", None) + assert fmgr.events[0][1]["link_id"] == "registry-link" + def test_exception_does_not_kill_listener(self): fmgr = FakeMarkerManager() lis = MarkerListener(fmgr) From 08f4b5ae0d00ca0cb1d3402053fc88d0fd07ebf0 Mon Sep 17 00:00:00 2001 From: YueGuobin Date: Tue, 14 Jul 2026 23:52:50 +0800 Subject: [PATCH 30/31] docs(marker): document per-link attribution and IOU multi-interface Add a Per-link attribution section (link field semantics, pcap path keyed on link) with an IOU subsection explaining the shared IOL-BRIDGE and per-port filter lists. Note that the marker.match link_id comes from the signal link= field, and call out shared capture-side node behavior in Notes. --- docs/features/marker-traffic-insight.md | 43 +++++++++++++++++++++++-- 1 file changed, 40 insertions(+), 3 deletions(-) diff --git a/docs/features/marker-traffic-insight.md b/docs/features/marker-traffic-insight.md index 86f893469..84ded58e1 100644 --- a/docs/features/marker-traffic-insight.md +++ b/docs/features/marker-traffic-insight.md @@ -42,8 +42,9 @@ graph TB Inheritance is a controller-only fan-out: a definition CRUD loops over links and reuses the existing per-link marker operations, so the compute side sees an ordinary marker and is -unchanged. Each compute process runs one UDP listener serving every uBridge on that host; -the `node` field in each signal identifies the source. +unchanged. Each compute process runs one UDP listener serving every uBridge on that host; the +`node` and `link` fields in each signal together identify the source link (see +[Per-link attribution](#per-link-attribution)). ## Business Process @@ -71,6 +72,34 @@ Updating a definition syncs `bpf / tag / color / highlight_duration` to every in copy; deleting a definition removes every inherited copy. A newly created link inherits all existing definitions automatically. +## Per-link attribution + +A uBridge `MARK` signal carries `node`, `filter`, `link`, `tag`, and `len` — but no bridge +name. When one node is the capture side for several links — the common case for a project-level +`global-{name}` marker on a multi-interface router — `node` + `filter` alone are identical +across those links, so they cannot tell the signals (or pcap files) apart. The `link` field +resolves this: + +1. At install time the controller stamps each filter with its link id + (`mark [tag ] link [pcap ]`). +2. uBridge treats `link` as opaque and echoes it verbatim in the signal (`link=`). +3. The listener takes the signal's `link=` as the **authoritative** `link_id` of the + `marker.match` event, falling back to its registry only for legacy signals that carry no + `link=`. + +This is also why the pcap path is keyed on link — +`/markers/__.pcap`, not on `bridge`+`filter`: a single +uBridge bridge can serve several links, and only the link id keeps their captures distinct. + +### IOU: one bridge, many interfaces + +IOU runs a single `IOL-BRIDGE` per node shared by every interface, so `bridge`+`filter` are +identical across that node's links. uBridge keeps a separate filter list **per port +(bay/unit)** within the bridge, so each interface gets its own `global-{name}` filter, its own +pcap file, and its own `link=`. The shared bridge name is irrelevant to attribution. Other +capable node types (`qemu`, `docker`, `vpcs`, `cloud`) already use one bridge per link; `link` +applies uniformly to all of them. + ## API Endpoints All endpoints require a JWT bearer token (`POST /v3/access/users/authenticate`). The @@ -190,6 +219,9 @@ extra request. | `link.updated` | Link object (its `markers` field is the source of truth) | Project notification ws | | `marker.match` | `project_id`, `node_id`, `link_id`, `filter`, `tag`, `ts`, `len` | Project notification ws only | +The `marker.match` `link_id` is taken from the signal's `link=` field (authoritative); see +[Per-link attribution](#per-link-attribution). + ## Error Responses | Status | Description | @@ -215,7 +247,12 @@ extra request. default. A partial PUT (e.g. changing only `bpf`) leaves them untouched. - **Supported node types.** A marker needs a uBridge bridge: `vpcs`, `qemu`, `docker`, `iou`, `dynamips`, `cloud` (one capable endpoint suffices). Types without a uBridge are - silently skipped by the inheritance fan-out. + silently skipped by the inheritance fan-out. IOU uses one shared `IOL-BRIDGE` per node but + keeps filters, pcap files, and `link=` ids per port, so multi-interface nodes are handled + (see [Per-link attribution](#per-link-attribution)). +- **Shared capture-side node.** When one node hosts markers for several links (typical for + `global-*` definitions on a router), each filter is stamped with its `link_id` so signals + and pcap files stay link-distinct; the controller never collapses them to a single link. - **Persistence.** Definitions and private markers persist in the topology; inherited markers are re-created from definitions on project load, so reopening a project restores the same configuration and stale inherited copies cannot survive on disk. From 45d18de8af1a5a46fd760789da01c964e22ce1c9 Mon Sep 17 00:00:00 2001 From: YueGuobin Date: Wed, 15 Jul 2026 01:27:42 +0800 Subject: [PATCH 31/31] fix(marker): avoid reentrant-lock deadlock when uBridge starts lazily MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit _start_ubridge configures the marker sink via _ubridge_configure_marker_sink, which used _ubridge_send. But _start_ubridge itself is reached THROUGH _ubridge_send when uBridge is started lazily — e.g. linking a stopped node. _ubridge_send's @locking lock (___ubridge_send_lock) is non-reentrant, so the nested _ubridge_send('marker sink') deadlocked forever, making the NIO create POST time out after 120s. Started nodes never hit this (uBridge already running, no _start_ubridge), so only stopped-node links (notably dynamips) hung. _ubridge_configure_marker_sink now talks to self._ubridge_hypervisor.send directly: it runs inside _start_ubridge right after connect(), so uBridge is already up and the raw send is safe, with no reentrant lock acquisition. --- gns3server/compute/base_node.py | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/gns3server/compute/base_node.py b/gns3server/compute/base_node.py index 24d7813ae..df92de0aa 100644 --- a/gns3server/compute/base_node.py +++ b/gns3server/compute/base_node.py @@ -958,9 +958,17 @@ class BaseNode: manager = MarkerManager.instance() if not manager.running or not manager.host or not manager.port: return + if self._ubridge_hypervisor is None: + return try: - await self._ubridge_send(f"marker sink {manager.host} {manager.port}") - await self._ubridge_send(f"marker node {self._id}") + # 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",