From 6749b872fae404a31e407d73e5301782107feac4 Mon Sep 17 00:00:00 2001 From: YueGuobin Date: Sat, 1 Aug 2026 14:52:07 +0800 Subject: [PATCH] marker: forward dir= from ubridge and add per-marker direction filter Direction field in marker.match events ======================================= Read ubridge dir= from MARK signal datagrams and forward it as a "dir" key in the marker.match notification event. The field is additive -- older ubridge builds omit it and the parser leaves it null, so consumers fall back to undirected rendering with no version coupling. Semantics are relative to the capture node (the signal's node=): tx = capture node is sending (ingressed device-side NIO), rx = it is receiving (ingressed link-side NIO). Per-marker direction filter (opt-in, server-side pipeline) ========================================================== Add a direction field to MarkerCreate and MarkerDefinitionCreate schemas ("tx" | "rx" | null). Plumb it through the full pipeline: Schema -> controller (start_marker/update_marker, marker_entry, _markers_for_node, update_marker_definition sync) -> REST/MCP handlers -> compute _ubridge_add_marker_filter + IOU _ubridge_apply_markers -> bridge add_packet_filter dir When set, ubridge only fires the mark handler (signal + pcap) for packets matching the chosen direction. null (default/legacy) = both directions -- zero behavioural change for existing markers. Docs and tests ============== - docs/features/marker-traffic-insight.md: signal format updated, new Direction section with NIO mapping, arrow mapping, and additive compatibility note. - tests/compute/marker/test_marker_manager.py: 3 new parser tests (dir tx/rx/absent) plus existing test extended to assert dir=None. 13 files, +123/-22, 72 tests pass (zero breakage) --- docs/features/marker-traffic-insight.md | 30 ++++++++++++++++++-- gns3server/api/routes/controller/links.py | 2 ++ gns3server/api/routes/controller/projects.py | 2 ++ gns3server/api/routes/mcp/__init__.py | 6 ++-- gns3server/api/routes/mcp/links.py | 8 +++--- gns3server/compute/base_node.py | 7 +++-- gns3server/compute/iou/iou_vm.py | 3 ++ gns3server/compute/marker/marker_listener.py | 21 ++++++++++++-- gns3server/controller/link.py | 5 ++-- gns3server/controller/project.py | 10 ++++--- gns3server/controller/udp_link.py | 9 ++++-- gns3server/schemas/controller/links.py | 10 +++++++ tests/compute/marker/test_marker_manager.py | 30 ++++++++++++++++++++ 13 files changed, 122 insertions(+), 21 deletions(-) diff --git a/docs/features/marker-traffic-insight.md b/docs/features/marker-traffic-insight.md index 84ded58e1..42b16d177 100644 --- a/docs/features/marker-traffic-insight.md +++ b/docs/features/marker-traffic-insight.md @@ -100,6 +100,31 @@ pcap file, and its own `link=`. The shared bridge name is irrelevant to attribut capable node types (`qemu`, `docker`, `vpcs`, `cloud`) already use one bridge per link; `link` applies uniformly to all of them. +## Direction + +A `MARK` signal optionally carries `dir=` — the matched packet's travel direction +**relative to the capture node** (the `node=` in the same signal, i.e. the node whose +uBridge hosts the marker): + +| `dir` | Ingress NIO | Meaning | +|-------|-------------|---------| +| `tx` | device side (`source_nio` on a generic bridge; the IOL instance on an IOU `IOL-BRIDGE`) | capture node is **sending** | +| `rx` | link side (`destination_nio` on a generic bridge; the NIO side on an IOU `IOL-BRIDGE`) | capture node is **receiving** | + +A marker is single-sided: only the chosen capture node's uBridge installs the `mark` filter, +yet both directions of the link transit that one bridge (it carries exactly two NIOs — the +device side and the link side), so that single uBridge observes and classifies both +directions. The `marker.match` event forwards `dir` through unchanged; the Web UI combines it +with the link's two endpoints and the capture `node_id` to draw an arrow: + +- `dir=tx` → `capture_node → far_node` +- `dir=rx` → `far_node → capture_node` +- `dir` absent (older uBridge) → undirected highlight (current behaviour) + +Because the listener ignores unknown keys, `dir` is **additive**: an older server silently +drops it and an older uBridge simply omits it — either way the system falls back to +undirected rendering with no error. + ## API Endpoints All endpoints require a JWT bearer token (`POST /v3/access/users/authenticate`). The @@ -217,10 +242,11 @@ extra request. | 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 | +| `marker.match` | `project_id`, `node_id`, `link_id`, `filter`, `tag`, `ts`, `len`, `dir` | 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). +[Per-link attribution](#per-link-attribution). The `dir` field is the matched packet's travel +direction relative to the capture node; see [Direction](#direction). ## Error Responses diff --git a/gns3server/api/routes/controller/links.py b/gns3server/api/routes/controller/links.py index 9e33b7ba3..8d9f105fb 100644 --- a/gns3server/api/routes/controller/links.py +++ b/gns3server/api/routes/controller/links.py @@ -465,6 +465,7 @@ async def create_marker( name=name, bpf=marker_data.bpf, tag=marker_data.tag, + direction=marker_data.direction, color=marker_data.color, highlight_duration=marker_data.highlight_duration, ) @@ -508,6 +509,7 @@ async def update_marker( name=marker_name, bpf=marker_data.bpf if marker_data.bpf else None, tag=marker_data.tag, + direction=marker_data.direction, color=marker_data.color, enabled=marker_data.enabled, highlight_duration=marker_data.highlight_duration, diff --git a/gns3server/api/routes/controller/projects.py b/gns3server/api/routes/controller/projects.py index 5e6f1c6de..de2e293a1 100644 --- a/gns3server/api/routes/controller/projects.py +++ b/gns3server/api/routes/controller/projects.py @@ -267,6 +267,7 @@ async def create_marker_definition( name=name, bpf=def_data.bpf, tag=def_data.tag, + direction=def_data.direction, color=def_data.color, highlight_duration=def_data.highlight_duration, ) @@ -292,6 +293,7 @@ async def update_marker_definition( name=def_name, bpf=def_data.bpf if def_data.bpf else None, tag=def_data.tag, + direction=def_data.direction, color=def_data.color, highlight_duration=def_data.highlight_duration, ) diff --git a/gns3server/api/routes/mcp/__init__.py b/gns3server/api/routes/mcp/__init__.py index f7d57d6e2..e99b9eb2e 100644 --- a/gns3server/api/routes/mcp/__init__.py +++ b/gns3server/api/routes/mcp/__init__.py @@ -1015,6 +1015,7 @@ async def link_marker( name: Annotated[str | None, Field(description="Custom marker name for create action (auto-generated if omitted)")] = None, tag: Annotated[int | None, Field(description="Numeric tag for packet correlation")] = None, enabled: Annotated[bool | None, Field(description="Enable or disable the marker (for update action)")] = None, + direction: Annotated[str | None, Field(description="Direction filter: 'tx' for capture node sending only, 'rx' for capture node receiving only (omit for both)")] = None, color: Annotated[str | None, Field(description="Hex color for UI highlight, e.g. '#ff5722'")] = None, highlight_duration: Annotated[int | None, Field(description="UI highlight duration in milliseconds")] = None, ) -> list[dict[str, Any]]: @@ -1033,7 +1034,7 @@ async def link_marker( and cannot be modified or deleted via this tool. """ params = {"project_id": project_id, "link_id": link_id, "action": action} - for opt in ("bpf", "marker_name", "name", "tag", "enabled", "color", "highlight_duration"): + for opt in ("bpf", "marker_name", "name", "tag", "enabled", "direction", "color", "highlight_duration"): val = locals().get(opt) if val is not None: params[opt] = val @@ -1050,6 +1051,7 @@ async def marker_definition( tag: Annotated[int | None, Field(description="Numeric tag for packet correlation")] = None, color: Annotated[str | None, Field(description="Hex color for UI highlight, e.g. '#ff5722'")] = None, highlight_duration: Annotated[int | None, Field(description="UI highlight duration in milliseconds")] = None, + direction: Annotated[str | None, Field(description="Direction filter: 'tx' for capture node sending only, 'rx' for capture node receiving only (omit for both)")] = None, ) -> list[dict[str, Any]]: """Manage project-level marker definitions — traffic-insight rules that apply to ALL links. @@ -1065,7 +1067,7 @@ async def marker_definition( Common BPF examples: 'arp', 'icmp', 'ospf', 'tcp port 22', 'udp port 53' """ params = {"project_id": project_id, "action": action} - for opt in ("bpf", "def_name", "name", "tag", "color", "highlight_duration"): + for opt in ("bpf", "def_name", "name", "tag", "direction", "color", "highlight_duration"): val = locals().get(opt) if val is not None: params[opt] = val diff --git a/gns3server/api/routes/mcp/links.py b/gns3server/api/routes/mcp/links.py index b2a0352c3..c69d392ff 100644 --- a/gns3server/api/routes/mcp/links.py +++ b/gns3server/api/routes/mcp/links.py @@ -395,7 +395,7 @@ def link_marker_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dic if not bpf: return {"error": "bpf is required for create action"} body: dict[str, Any] = {"bpf": bpf} - for opt in ("name", "tag", "color", "highlight_duration"): + for opt in ("name", "tag", "direction", "color", "highlight_duration"): if params.get(opt) is not None: body[opt] = params[opt] return conn.http_call("post", base, json_data=body).json() @@ -408,7 +408,7 @@ def link_marker_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) -> dic if action == "update": body = {} - for opt in ("bpf", "tag", "enabled", "color", "highlight_duration"): + for opt in ("bpf", "tag", "direction", "enabled", "color", "highlight_duration"): if params.get(opt) is not None: body[opt] = params[opt] if not body: @@ -448,7 +448,7 @@ def marker_definition_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) if not bpf: return {"error": "bpf is required for create action"} body: dict[str, Any] = {"bpf": bpf} - for opt in ("name", "tag", "color", "highlight_duration"): + for opt in ("name", "tag", "direction", "color", "highlight_duration"): if params.get(opt) is not None: body[opt] = params[opt] return conn.http_call("post", base, json_data=body).json() @@ -461,7 +461,7 @@ def marker_definition_handler(params: dict[str, Any], gns3_ctx: dict[str, Any]) if action == "update": body = {} - for opt in ("bpf", "tag", "color", "highlight_duration"): + for opt in ("bpf", "tag", "direction", "color", "highlight_duration"): if params.get(opt) is not None: body[opt] = params[opt] if not body: diff --git a/gns3server/compute/base_node.py b/gns3server/compute/base_node.py index df6fabddc..9b9ede270 100644 --- a/gns3server/compute/base_node.py +++ b/gns3server/compute/base_node.py @@ -1084,7 +1084,7 @@ class BaseNode: ) i += 1 - async def _ubridge_add_marker_filter(self, bridge_name, name, bpf, pcap_path, tag=None, link_id=None): + async def _ubridge_add_marker_filter(self, bridge_name, name, bpf, pcap_path, tag=None, link_id=None, direction=None): """ Attach a `mark` packet filter to a uBridge bridge for traffic insight. @@ -1120,6 +1120,8 @@ class BaseNode: # so the link id is the only way to tell signals — and pcap files — apart. if link_id: cmd += f" link {link_id}" + if direction is not None: + cmd += f" dir {direction}" 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. @@ -1149,7 +1151,8 @@ 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, link_id) + await self._ubridge_add_marker_filter(bridge_name, name, bpf, pcap_path, tag, link_id, + direction=spec.get("direction")) 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 3697fc327..f2936f044 100644 --- a/gns3server/compute/iou/iou_vm.py +++ b/gns3server/compute/iou/iou_vm.py @@ -1308,6 +1308,9 @@ class IOUVM(BaseNode): # controller can tell their signals apart (contract §3.2). if link_id: cmd += f" link {link_id}" + direction = spec.get("direction") + if direction is not None: + cmd += f" dir {direction}" 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 f22f59bff..2b5e5d47f 100644 --- a/gns3server/compute/marker/marker_listener.py +++ b/gns3server/compute/marker/marker_listener.py @@ -28,9 +28,18 @@ class MarkerListener(asyncio.DatagramProtocol): Signal format (one datagram per match, ASCII):: - MARK node= filter= tag= len=\\n + MARK node= filter= tag= len= [dir=]\\n - The signal carries metadata only (no packet bytes). The compute-side + The signal carries metadata only (no packet bytes). ``dir`` is optional and + additive: uBridge stamps it from the ingress NIO of the matched packet to + indicate travel direction relative to the capture node (the ``node=`` + above) — ``tx`` = the capture node is sending (ingressed on the device-side + NIO), ``rx`` = it is receiving (ingressed on the link-side NIO). Older + uBridge builds omit it, so the listener leaves ``dir`` unset and consumers + fall back to undirected rendering. Unknown keys are always ignored, so the + field ships safely with no version coupling. + + 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. @@ -82,6 +91,11 @@ class MarkerListener(asyncio.DatagramProtocol): link = kv.get("link") tag = kv.get("tag") length = kv.get("len") + # Travel direction relative to the capture node (the node= above): + # "tx" = capture node is sending (matched packet ingressed on the + # device-side NIO), "rx" = it is receiving (link-side NIO). Older + # uBridge builds omit dir; None here lets consumers render undirected. + direction = kv.get("dir") project_id, link_id, registered_tag = self._manager.lookup(node_id, filter_name) if project_id is None: @@ -105,5 +119,8 @@ class MarkerListener(asyncio.DatagramProtocol): "tag": tag if tag and tag != "-" else registered_tag, "ts": ts, "len": int(length) if length and length.isdigit() else 0, + # Travel direction relative to the capture node (node_id above); + # None when the signal carries none (older uBridge) — undirected. + "dir": direction, } self._manager.emit_match(project_id, event) diff --git a/gns3server/controller/link.py b/gns3server/controller/link.py index 008deda4d..a766e107c 100644 --- a/gns3server/controller/link.py +++ b/gns3server/controller/link.py @@ -121,6 +121,7 @@ class Link: name=f"global-{def_name}", bpf=marker_def["bpf"], tag=marker_def.get("tag"), + direction=marker_def.get("direction"), color=marker_def.get("color"), highlight_duration=marker_def.get("highlight_duration"), inherited_from=def_name, @@ -333,7 +334,7 @@ class Link: raise NotImplementedError - async def start_marker(self, name, bpf, tag=None): + async def start_marker(self, name, bpf, tag=None, direction=None): """ Attach a traffic-insight marker to this link (base — UDPLink overrides). """ @@ -345,7 +346,7 @@ class Link: """ raise NotImplementedError - async def update_marker(self, name, bpf=None, tag=None, enabled=None): + async def update_marker(self, name, bpf=None, tag=None, enabled=None, direction=None): """ Update an existing marker's BPF, tag, or enabled flag. diff --git a/gns3server/controller/project.py b/gns3server/controller/project.py index 9ada6506a..10b94b620 100644 --- a/gns3server/controller/project.py +++ b/gns3server/controller/project.py @@ -930,7 +930,7 @@ class Project: """ return self._marker_definitions - async def create_marker_definition(self, name, bpf, tag=None, color=None, highlight_duration=None): + async def create_marker_definition(self, name, bpf, tag=None, direction=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, "highlight_duration": highlight_duration} + self._marker_definitions[name] = {"bpf": bpf, "tag": tag, "direction": direction, "color": color, "highlight_duration": highlight_duration} await self._apply_def_to_all_links(name) self.dump() self.emit_notification("project.updated", self.asdict()) - async def update_marker_definition(self, name, bpf=None, tag=None, color=None, highlight_duration=None): + async def update_marker_definition(self, name, bpf=None, tag=None, direction=None, color=None, highlight_duration=None): """ Update a marker definition and sync every inherited copy on every link. """ @@ -966,13 +966,15 @@ class Project: d["color"] = color if highlight_duration is not None: d["highlight_duration"] = highlight_duration + if direction is not None: + d["direction"] = direction # 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"), + marker_name, bpf=d["bpf"], tag=d.get("tag"), direction=d.get("direction"), color=d.get("color"), highlight_duration=d.get("highlight_duration"), inherited=True ) self.dump() diff --git a/gns3server/controller/udp_link.py b/gns3server/controller/udp_link.py index 609c17495..146f55de6 100644 --- a/gns3server/controller/udp_link.py +++ b/gns3server/controller/udp_link.py @@ -62,7 +62,7 @@ class UDPLink(Link): 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} + name: {"bpf": m["bpf"], "tag": m.get("tag"), "link_id": self._id, "direction": m.get("direction")} for name, m in self._markers.items() if m.get("enabled", True) and m.get("capture_node_id") == node.id } @@ -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, highlight_duration=None, inherited_from=None): + async def start_marker(self, name, bpf, tag=None, direction=None, color=None, highlight_duration=None, inherited_from=None): """ Attach a traffic-insight marker to this link. @@ -358,6 +358,7 @@ class UDPLink(Link): "color": color, "highlight_duration": highlight_duration, "capture_node_id": marker_side["node"].id, + "direction": direction, } if inherited_from: marker_entry["inherited_from"] = inherited_from @@ -396,7 +397,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, highlight_duration=None, inherited=False): + async def update_marker(self, name, bpf=None, tag=None, enabled=None, direction=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 @@ -436,6 +437,8 @@ class UDPLink(Link): marker_info["color"] = color if highlight_duration is not None: marker_info["highlight_duration"] = highlight_duration + if direction is not None: + marker_info["direction"] = direction if self._created: await self.update() diff --git a/gns3server/schemas/controller/links.py b/gns3server/schemas/controller/links.py index 23a0cd18a..44f8ac1aa 100644 --- a/gns3server/schemas/controller/links.py +++ b/gns3server/schemas/controller/links.py @@ -175,6 +175,11 @@ class MarkerCreate(BaseModel): None, description="Whether the marker is active. Defaults to true on creation.", ) + direction: Optional[str] = Field( + None, + pattern=r"^(tx|rx)$", + description="Direction filter: 'tx' = capture node sending only, 'rx' = capture node receiving only. Omitted or null = both directions.", + ) class MarkerDefinitionCreate(BaseModel): @@ -207,5 +212,10 @@ class MarkerDefinitionCreate(BaseModel): "stored with the definition, never sent to uBridge." ), ) + direction: Optional[str] = Field( + None, + pattern=r"^(tx|rx)$", + description="Direction filter: 'tx' = capture node sending only, 'rx' = capture node receiving only. Omitted or null = both directions.", + ) diff --git a/tests/compute/marker/test_marker_manager.py b/tests/compute/marker/test_marker_manager.py index 4a2796002..da495ae92 100644 --- a/tests/compute/marker/test_marker_manager.py +++ b/tests/compute/marker/test_marker_manager.py @@ -124,6 +124,8 @@ class TestMarkerListener: assert ev["tag"] == "7" assert ev["ts"] == pytest.approx(1700000000.123456) assert ev["len"] == 98 + # No dir= in the signal (legacy uBridge) → undirected. + assert ev["dir"] is None def test_unknown_node_dropped(self): fmgr = FakeMarkerManager() @@ -183,6 +185,34 @@ class TestMarkerListener: 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_dir_tx_passthrough(self): + # dir=tx = capture node sending (matched packet ingressed the device-side NIO). + fmgr = FakeMarkerManager() + fmgr.register("p", "n", "f", "l", tag=1) + lis = MarkerListener(fmgr) + lis.connection_made(None) + lis.datagram_received(b"MARK 1.0 node=n filter=f tag=1 len=10 dir=tx\n", None) + assert fmgr.events[0][1]["dir"] == "tx" + + def test_dir_rx_passthrough(self): + # dir=rx = capture node receiving (matched packet ingressed the link-side NIO). + fmgr = FakeMarkerManager() + fmgr.register("p", "n", "f", "l", tag=1) + lis = MarkerListener(fmgr) + lis.connection_made(None) + lis.datagram_received(b"MARK 1.0 node=n filter=f tag=1 len=10 dir=rx\n", None) + assert fmgr.events[0][1]["dir"] == "rx" + + def test_dir_absent_is_none(self): + # Older uBridge builds omit dir; the event then carries None so the UI + # falls back to undirected rendering. + fmgr = FakeMarkerManager() + fmgr.register("p", "n", "f", "l", tag=1) + lis = MarkerListener(fmgr) + lis.connection_made(None) + lis.datagram_received(b"MARK 1.0 node=n filter=f tag=1 len=10\n", None) + assert fmgr.events[0][1]["dir"] is None + def test_exception_does_not_kill_listener(self): fmgr = FakeMarkerManager() lis = MarkerListener(fmgr)