From 33222336587b4ae9a60e275abeaa6a573a2e8e17 Mon Sep 17 00:00:00 2001 From: YueGuobin Date: Fri, 7 Aug 2026 01:53:33 +0800 Subject: [PATCH] marker: serial-link (WAN) support via data_link_type -> uBridge linktype Markers now work on serial links (Cisco HDLC / PPP / Frame Relay / ATM), not just Ethernet. A marker carries a data_link_type (default DLT_EN10MB); at the uBridge boundary it becomes the 'mark ... linktype ' keyword so the BPF compiles and the pcap is written with the matching link-layer. - MarkerCreate / MarkerDefinitionCreate gain data_link_type (default DLT_EN10MB). Per-link it is create-only; definitions are updatable (a change re-fans-out). - base_node._marker_linktype() normalizes the GNS3 DLT name (strip DLT_, uppercase, None for EN10MB). Single source is SerialPort.data_link_types, so Cisco PPP -> PPP_SERIAL (50), matching the capture path -- no second mapping table. - _ubridge_add_marker_filter (generic) and the IOU marker loop append 'linktype '. - Definition fan-out branches on link_type in inherit_marker: Ethernet is always EN10MB; a serial link uses the definition's WAN encapsulation, or is SKIPPED when none was chosen (an EN10MB pcap on serial is undecodable). One definition covers a mixed topology. - MCP marker_definition exposes data_link_type (None = not forwarded). - No uBridge rebuild on a data_link_type change -- only that one marker's filter is swapped (delete + re-add), mirroring a BPF change; reset_packet_filters preserves sibling mark filters. Requires the uBridge build with 'mark ... linktype' support. --- gns3server/api/routes/controller/projects.py | 2 + gns3server/api/routes/mcp/__init__.py | 5 +- gns3server/api/routes/mcp/links.py | 6 +- gns3server/compute/base_node.py | 26 ++++++- gns3server/compute/iou/iou_vm.py | 3 + gns3server/controller/link.py | 14 ++++ gns3server/controller/project.py | 40 ++++++++--- gns3server/controller/udp_link.py | 18 +++-- gns3server/schemas/controller/links.py | 24 +++++++ tests/compute/test_base_node.py | 46 ++++++++++++ tests/controller/test_marker.py | 76 ++++++++++++++++++-- 11 files changed, 231 insertions(+), 29 deletions(-) diff --git a/gns3server/api/routes/controller/projects.py b/gns3server/api/routes/controller/projects.py index c9c1c0555..f9c838a34 100644 --- a/gns3server/api/routes/controller/projects.py +++ b/gns3server/api/routes/controller/projects.py @@ -271,6 +271,7 @@ async def create_marker_definition( direction=def_data.direction, color=def_data.color, highlight_duration=def_data.highlight_duration, + data_link_type=def_data.data_link_type, ) return project.marker_definitions.get(name, {}) @@ -297,6 +298,7 @@ async def update_marker_definition( direction=def_data.direction if "direction" in def_data.model_fields_set else _UNSET, color=def_data.color, highlight_duration=def_data.highlight_duration, + data_link_type=def_data.data_link_type if "data_link_type" in def_data.model_fields_set else _UNSET, ) return project.marker_definitions.get(def_name, {}) diff --git a/gns3server/api/routes/mcp/__init__.py b/gns3server/api/routes/mcp/__init__.py index 2369fe658..021f66b99 100644 --- a/gns3server/api/routes/mcp/__init__.py +++ b/gns3server/api/routes/mcp/__init__.py @@ -1052,6 +1052,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, + data_link_type: Annotated[str | None, Field(description="pcap link-layer type for serial links (DLT_C_HDLC / DLT_PPP_SERIAL / DLT_FRELAY / DLT_ATM_RFC1483). Omit = Ethernet-only (serial links skipped); setting it also covers serial links with that encapsulation")] = None, ) -> list[dict[str, Any]]: """Manage project-level marker definitions — traffic-insight rules that apply to ALL links. @@ -1060,7 +1061,7 @@ async def marker_definition( On delete, 'global-{name}' is removed from every link. Create requires: project_id, action='create', bpf - Update requires: project_id, action='update', def_name, and at least one of (bpf, tag, color, highlight_duration) + Update requires: project_id, action='update', def_name, and at least one of (bpf, tag, color, highlight_duration, data_link_type) Delete requires: project_id, action='delete', def_name List requires: project_id, action='list' @@ -1073,7 +1074,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", "color", "highlight_duration", "data_link_type"): 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 7e1afc6a2..314f8b8c9 100644 --- a/gns3server/api/routes/mcp/links.py +++ b/gns3server/api/routes/mcp/links.py @@ -457,7 +457,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", "color", "highlight_duration", "data_link_type"): if params.get(opt) is not None: body[opt] = params[opt] # No direction: a definition fans out to every link and auto-selects its @@ -473,11 +473,11 @@ 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", "color", "highlight_duration", "data_link_type"): if params.get(opt) is not None: body[opt] = params[opt] if not body: - return {"error": "At least one update field is required (bpf, tag, color, highlight_duration)"} + return {"error": "At least one update field is required (bpf, tag, color, highlight_duration, data_link_type)"} return conn.http_call("put", url, json_data=body).json() # action == "delete" diff --git a/gns3server/compute/base_node.py b/gns3server/compute/base_node.py index aaf3da9a1..fc048892a 100644 --- a/gns3server/compute/base_node.py +++ b/gns3server/compute/base_node.py @@ -1091,7 +1091,25 @@ class BaseNode: ) i += 1 - async def _ubridge_add_marker_filter(self, bridge_name, name, bpf, pcap_path, tag=None, link_id=None, direction=None): + @staticmethod + def _marker_linktype(data_link_type): + """ + Normalize a GNS3 pcap data-link type (e.g. ``DLT_C_HDLC``) to the bare + uBridge ``linktype`` token (``C_HDLC``) by stripping the ``DLT_`` prefix. + Returns ``None`` for Ethernet (``DLT_EN10MB`` / unset) so the ``linktype`` + keyword is omitted and uBridge defaults to EN10MB. Values come straight + from ``SerialPort.data_link_types`` (the single source of truth); uBridge + resolves them with ``pcap_datalink_name_to_val``, which is case-sensitive + and expects the canonical uppercase form. + """ + if not data_link_type: + return None + dlt = data_link_type.upper() + if dlt.startswith("DLT_"): + dlt = dlt[4:] + return None if dlt == "EN10MB" else dlt + + async def _ubridge_add_marker_filter(self, bridge_name, name, bpf, pcap_path, tag=None, link_id=None, direction=None, data_link_type=None): """ Attach a `mark` packet filter to a uBridge bridge for traffic insight. @@ -1132,6 +1150,9 @@ class BaseNode: cmd += f" link {link_id}" if direction is not None: cmd += f" dir {direction}" + linktype = self._marker_linktype(data_link_type) + if linktype is not None: + cmd += f" linktype {linktype}" 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. @@ -1232,7 +1253,8 @@ class BaseNode: ) try: await self._ubridge_add_marker_filter(bridge_name, name, bpf, pcap_path, tag, link_id, - direction=spec.get("direction")) + direction=spec.get("direction"), + data_link_type=spec.get("data_link_type")) 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 52a8f63d9..5803a9816 100644 --- a/gns3server/compute/iou/iou_vm.py +++ b/gns3server/compute/iou/iou_vm.py @@ -1311,6 +1311,9 @@ class IOUVM(BaseNode): direction = spec.get("direction") if direction is not None: cmd += f" dir {direction}" + linktype = self._marker_linktype(spec.get("data_link_type")) + if linktype is not None: + cmd += f" linktype {linktype}" cmd += ' pcap "{path}"'.format(path=pcap_path) try: await self._ubridge_send(cmd) diff --git a/gns3server/controller/link.py b/gns3server/controller/link.py index 607810d23..4467a0b4e 100644 --- a/gns3server/controller/link.py +++ b/gns3server/controller/link.py @@ -122,13 +122,27 @@ class Link: 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. + + The pcap link-layer follows the link type: Ethernet is always EN10MB. + A serial link needs the definition's WAN encapsulation (HDLC / PPP / + Frame Relay); if none was chosen the serial link is skipped — an EN10MB + pcap on a serial link is undecodable. """ + def_data_link_type = marker_def.get("data_link_type", "DLT_EN10MB") + if self._link_type == "serial": + if def_data_link_type.upper() == "DLT_EN10MB": + return # definition is Ethernet-only; skip this serial link + data_link_type = def_data_link_type + else: + data_link_type = "DLT_EN10MB" + await self.start_marker( name=f"global-{def_name}", bpf=marker_def["bpf"], tag=marker_def.get("tag"), direction=marker_def.get("direction"), + data_link_type=data_link_type, color=marker_def.get("color"), highlight_duration=marker_def.get("highlight_duration"), enabled=not marker_def.get("paused", False), diff --git a/gns3server/controller/project.py b/gns3server/controller/project.py index 831fa1952..785ca2eae 100644 --- a/gns3server/controller/project.py +++ b/gns3server/controller/project.py @@ -1000,7 +1000,7 @@ class Project: "For a capture-node-relative direction on a single link, use a per-link marker." ) - async def create_marker_definition(self, name, bpf, tag=None, direction=None, color=None, highlight_duration=None): + async def create_marker_definition(self, name, bpf, tag=None, direction=None, color=None, highlight_duration=None, data_link_type="DLT_EN10MB"): """ 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 @@ -1014,12 +1014,12 @@ class Project: self._validate_marker_definition_bpf(name, bpf) self._validate_marker_definition_direction(name, direction) - self._marker_definitions[name] = {"bpf": bpf, "tag": tag, "direction": direction, "color": color, "highlight_duration": highlight_duration, "paused": False} + self._marker_definitions[name] = {"bpf": bpf, "tag": tag, "direction": direction, "color": color, "highlight_duration": highlight_duration, "data_link_type": data_link_type, "paused": False} 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, direction=_UNSET, color=None, highlight_duration=None): + async def update_marker_definition(self, name, bpf=None, tag=None, direction=_UNSET, color=None, highlight_duration=None, data_link_type=_UNSET): """ Update a marker definition and sync every inherited copy on every link. """ @@ -1042,15 +1042,33 @@ class Project: if direction is not _UNSET: self._validate_marker_definition_direction(name, direction) d["direction"] = direction # None = clear back to both directions + if data_link_type is not _UNSET: + d["data_link_type"] = data_link_type - # 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"), direction=d.get("direction"), color=d.get("color"), - highlight_duration=d.get("highlight_duration"), inherited=True - ) + if data_link_type is not _UNSET: + # data_link_type decides which links host an inherited copy (serial + # links are skipped unless a WAN encapsulation is chosen), so a change + # needs a full re-fan-out: drop every copy, then re-apply. + 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, inherited=True) + except ControllerError: + log.warning( + "Failed to remove inherited marker %s from link %s", + marker_name, link.id + ) + await self._apply_def_to_all_links(name) + else: + # 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"), direction=d.get("direction"), 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 68b06b27f..d9ca8f250 100644 --- a/gns3server/controller/udp_link.py +++ b/gns3server/controller/udp_link.py @@ -57,15 +57,18 @@ class UDPLink(Link): def _markers_for_node(self, node): """ - Marker specs (name -> {bpf, tag, link_id, direction, enabled}) for the - markers whose capture side is ``node``. Routed by capture_node_id so a - marker only rides the NIO of the node whose uBridge will host it. A - disabled marker is included (installed then turned ``off`` at uBridge, - not dropped) so the UI can toggle it instantly without an NIO rebuild. + Marker specs (name -> {bpf, tag, link_id, direction, data_link_type, + enabled}) for the markers whose capture side is ``node``. Routed by + capture_node_id so a marker only rides the NIO of the node whose uBridge + will host it. A disabled marker is included (installed then turned + ``off`` at uBridge, not dropped) so the UI can toggle it instantly + without an NIO rebuild. """ return { name: {"bpf": m["bpf"], "tag": m.get("tag"), "link_id": self._id, - "direction": m.get("direction"), "enabled": m.get("enabled", True)} + "direction": m.get("direction"), + "data_link_type": m.get("data_link_type", "DLT_EN10MB"), + "enabled": m.get("enabled", True)} for name, m in self._markers.items() if m.get("capture_node_id") == node.id } @@ -350,7 +353,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, direction=None, capture_node_id=None, color=None, highlight_duration=None, enabled=True, inherited_from=None): + async def start_marker(self, name, bpf, tag=None, direction=None, data_link_type="DLT_EN10MB", capture_node_id=None, color=None, highlight_duration=None, enabled=True, inherited_from=None): """ Attach a traffic-insight marker to this link. @@ -401,6 +404,7 @@ class UDPLink(Link): "highlight_duration": highlight_duration, "capture_node_id": marker_side["node"].id, "direction": direction, + "data_link_type": data_link_type, } if inherited_from: marker_entry["inherited_from"] = inherited_from diff --git a/gns3server/schemas/controller/links.py b/gns3server/schemas/controller/links.py index 853293f8c..fef63f8b1 100644 --- a/gns3server/schemas/controller/links.py +++ b/gns3server/schemas/controller/links.py @@ -189,6 +189,19 @@ class MarkerCreate(BaseModel): "Omitted = server auto-picks (first started marker-capable endpoint)." ), ) + data_link_type: str = Field( + "DLT_EN10MB", + description=( + "pcap link-layer type the marker's BPF compiles against and its " + "capture file is written with (a uBridge `linktype` token). Defaults " + "to DLT_EN10MB (Ethernet), which is omitted from the uBridge command. " + "Only meaningful for serial links: set it to the matching serial DLT " + "from the port's data_link_types — DLT_C_HDLC / DLT_PPP_SERIAL / " + "DLT_FRELAY / DLT_ATM_RFC1483 — so the BPF offsets and pcap decode " + "match the encapsulation configured in IOS. Create-only (changing it " + "would invalidate the pcap)." + ), + ) @field_validator("direction", mode="before") @classmethod @@ -259,6 +272,17 @@ class MarkerDefinitionCreate(BaseModel): pattern=r"^(tx|rx|both)$", description="Direction filter: 'tx' = capture node sending only, 'rx' = capture node receiving only, 'both' or null = both directions.", ) + data_link_type: str = Field( + "DLT_EN10MB", + description=( + "pcap link-layer type for inherited markers on serial links (uBridge " + "`linktype`). Defaults to DLT_EN10MB (Ethernet): the definition then " + "applies only to Ethernet links and serial links are skipped. Set a " + "serial DLT — DLT_C_HDLC / DLT_PPP_SERIAL / DLT_FRELAY / " + "DLT_ATM_RFC1483 — to also cover serial links with that encapsulation; " + "Ethernet links stay EN10MB regardless. Changing it re-fans-out." + ), + ) @field_validator("direction", mode="before") @classmethod diff --git a/tests/compute/test_base_node.py b/tests/compute/test_base_node.py index 3eaadb9f9..38371538d 100644 --- a/tests/compute/test_base_node.py +++ b/tests/compute/test_base_node.py @@ -230,6 +230,52 @@ async def test_apply_markers_turns_disabled_filter_off(compute_project, manager) assert node._marker_filter_bridges["m", "L1"] == "VPCS-10" +def test_marker_linktype_normalizes(): + # Ethernet / unset → None (linktype omitted; uBridge defaults to EN10MB). + assert VPCSVM._marker_linktype(None) is None + assert VPCSVM._marker_linktype("") is None + assert VPCSVM._marker_linktype("DLT_EN10MB") is None + # Serial DLTs from SerialPort.data_link_types, DLT_ prefix stripped. + assert VPCSVM._marker_linktype("DLT_C_HDLC") == "C_HDLC" + assert VPCSVM._marker_linktype("DLT_PPP_SERIAL") == "PPP_SERIAL" + assert VPCSVM._marker_linktype("DLT_FRELAY") == "FRELAY" + assert VPCSVM._marker_linktype("DLT_ATM_RFC1483") == "ATM_RFC1483" + # Case-insensitive input → canonical uppercase (pcap_datalink_name_to_val is + # case-sensitive and expects the uppercase form). Shared by base_node and IOU. + assert VPCSVM._marker_linktype("dlt_c_hdlc") == "C_HDLC" + + +@pytest.mark.asyncio +async def test_apply_markers_appends_linktype_for_serial(compute_project, manager): + # A serial data_link_type reaches the uBridge mark command as `linktype C_HDLC` + # so the BPF offsets and pcap decode match the WAN encapsulation. + node = VPCSVM("test", "00010203-0405-0607-0809-0a0b0c0d0e0f", compute_project, manager) + node._ubridge_send = AsyncioMagicMock() + nio = NIOUDP(1234, "127.0.0.1", 4321) + nio.markers = {"m": {"bpf": "icmp", "tag": None, "link_id": "L1", + "direction": None, "data_link_type": "DLT_C_HDLC", "enabled": True}} + with patch("gns3server.compute.marker.marker_manager.MarkerManager") as mm: + mm.instance.return_value.register = MagicMock() + await node._ubridge_apply_markers("VPCS-10", nio) + sent = [c.args[0] for c in node._ubridge_send.call_args_list] + assert any("linktype C_HDLC" in s for s in sent) + + +@pytest.mark.asyncio +async def test_apply_markers_omits_linktype_for_ethernet(compute_project, manager): + # Ethernet (DLT_EN10MB) → no linktype keyword; uBridge defaults to EN10MB. + node = VPCSVM("test", "00010203-0405-0607-0809-0a0b0c0d0e0f", compute_project, manager) + node._ubridge_send = AsyncioMagicMock() + nio = NIOUDP(1234, "127.0.0.1", 4321) + nio.markers = {"m": {"bpf": "icmp", "tag": None, "link_id": "L1", + "direction": None, "data_link_type": "DLT_EN10MB", "enabled": True}} + with patch("gns3server.compute.marker.marker_manager.MarkerManager") as mm: + mm.instance.return_value.register = MagicMock() + await node._ubridge_apply_markers("VPCS-10", nio) + sent = [c.args[0] for c in node._ubridge_send.call_args_list] + assert not any("linktype" in s for s in sent) + + @pytest.mark.asyncio async def test_delete_marker_capture_removes_pcap_and_entry(compute_project, manager): # Deleting a marker's capture removes its pcap and forgets the bridge entry. diff --git a/tests/controller/test_marker.py b/tests/controller/test_marker.py index d57157f2d..1c547f1ad 100644 --- a/tests/controller/test_marker.py +++ b/tests/controller/test_marker.py @@ -34,6 +34,7 @@ from tests.utils import AsyncioMagicMock from gns3server.controller.udp_link import UDPLink from gns3server.controller.ports.ethernet_port import EthernetPort +from gns3server.controller.ports.serial_port import SerialPort from gns3server.controller.node import Node from gns3server.controller.controller_error import ControllerError, ControllerNotFoundError @@ -55,17 +56,21 @@ def _valid_bpf(): return stack -async def _make_link(project): - """Build a created UDPLink between two VPCS nodes on a mocked compute.""" +async def _make_link(project, port_cls=EthernetPort): + """Build a created UDPLink between two VPCS nodes on a mocked compute. + + ``port_cls`` defaults to EthernetPort; pass SerialPort for a serial link + (the link's link_type follows the port). + """ compute = MagicMock() compute.id = "local" compute.host = "example.com" node1 = Node(project, compute, "n1", node_type="vpcs") - node1._ports = [EthernetPort("E0", 0, 0, 0)] + node1._ports = [port_cls("E0", 0, 0, 0)] node2 = Node(project, compute, "n2", node_type="vpcs") - node2._ports = [EthernetPort("E0", 0, 0, 1)] + node2._ports = [port_cls("E0", 0, 0, 1)] async def subnet(_other): return ("192.168.1.1", "192.168.1.2") @@ -110,6 +115,29 @@ async def test_start_marker_stores_entry(project): assert "inherited_from" not in entry +@pytest.mark.asyncio +async def test_start_marker_stores_data_link_type(project): + # data_link_type is stored on the marker and flows into the per-node spec + # (the compute-side source for the uBridge `linktype` keyword). Serial-only; + # defaults to DLT_EN10MB when omitted (Ethernet → linktype omitted). + with _valid_bpf(): + link = await _make_link(project) + await link.start_marker("ospf", "ospf", data_link_type="DLT_C_HDLC") + + assert link.markers["ospf"]["data_link_type"] == "DLT_C_HDLC" + capture_id = link.markers["ospf"]["capture_node_id"] + capture_side = next(n for n in link._nodes if n["node"].id == capture_id) + assert link._markers_for_node(capture_side["node"])["ospf"]["data_link_type"] == "DLT_C_HDLC" + + # Default when omitted = Ethernet. + with _valid_bpf(): + link2 = await _make_link(project) + await link2.start_marker("icmp", "icmp") + cid = link2.markers["icmp"]["capture_node_id"] + cside = next(n for n in link2._nodes if n["node"].id == cid) + assert link2._markers_for_node(cside["node"])["icmp"]["data_link_type"] == "DLT_EN10MB" + + @pytest.mark.asyncio async def test_start_marker_pins_capture_node(project): # Auto-pick would choose node1 (first endpoint); pin to node2 explicitly. @@ -420,6 +448,46 @@ async def test_update_marker_definition_syncs(project): assert project.marker_definitions["arp"]["highlight_duration"] == 1500 +@pytest.mark.asyncio +async def test_definition_serial_dlt_fans_out_to_serial_link(project): + # A definition with a serial data_link_type covers serial links with that + # encapsulation AND ethernet links with EN10MB (one definition, mixed topo). + with _valid_bpf(): + serial_link = await _make_link(project, SerialPort) + eth_link = await _make_link(project) + await project.create_marker_definition("ospf", "ospf", data_link_type="DLT_C_HDLC") + + assert serial_link._link_type == "serial" + assert serial_link.markers["global-ospf"]["data_link_type"] == "DLT_C_HDLC" + assert eth_link.markers["global-ospf"]["data_link_type"] == "DLT_EN10MB" + + +@pytest.mark.asyncio +async def test_definition_default_skips_serial_link(project): + # Default (EN10MB) definition is Ethernet-only: serial links are skipped + # (an EN10MB pcap on a serial link would be undecodable). + with _valid_bpf(): + serial_link = await _make_link(project, SerialPort) + eth_link = await _make_link(project) + await project.create_marker_definition("arp", "arp") + + assert "global-arp" not in serial_link.markers + assert "global-arp" in eth_link.markers + + +@pytest.mark.asyncio +async def test_update_definition_data_link_type_refans_out(project): + # Changing data_link_type re-evaluates which links host the marker: a serial + # link skipped under the default gains the marker once a WAN DLT is chosen. + with _valid_bpf(): + serial_link = await _make_link(project, SerialPort) + await project.create_marker_definition("ospf", "ospf") + assert "global-ospf" not in serial_link.markers # default → serial skipped + await project.update_marker_definition("ospf", data_link_type="DLT_PPP_SERIAL") + + assert serial_link.markers["global-ospf"]["data_link_type"] == "DLT_PPP_SERIAL" + + @pytest.mark.asyncio async def test_delete_marker_definition_clears(project): """Regression: deleting a def must remove inherited copies from every link."""