marker: forward dir= from ubridge and add per-marker direction filter

Direction field in marker.match events
=======================================

Read ubridge dir=<tx|rx> 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=<id>):
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 <tx|rx>

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)
This commit is contained in:
YueGuobin 2026-08-01 14:52:07 +08:00
parent d729f76856
commit 6749b872fa
No known key found for this signature in database
13 changed files with 122 additions and 21 deletions

View File

@ -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=<tx|rx>` — the matched packet's travel direction
**relative to the capture node** (the `node=<id>` 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

View File

@ -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,

View File

@ -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,
)

View File

@ -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

View File

@ -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:

View File

@ -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

View File

@ -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)

View File

@ -28,9 +28,18 @@ class MarkerListener(asyncio.DatagramProtocol):
Signal format (one datagram per match, ASCII)::
MARK <sec.usec> node=<id> filter=<name> tag=<tag> len=<n>\\n
MARK <sec.usec> node=<id> filter=<name> tag=<tag> len=<n> [dir=<tx|rx>]\\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=<id>``
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=<id> 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)

View File

@ -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.

View File

@ -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()

View File

@ -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()

View File

@ -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.",
)

View File

@ -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)