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)