mirror of
https://github.com/GNS3/gns3-server.git
synced 2026-11-03 05:51:15 +02:00
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.
This commit is contained in:
21 files changed
+1135
-1
No files matched your search
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
Reference in new issue
Block a user