diff --git a/docs/features/builtin-ethernet-switch-ubridge.md b/docs/features/builtin-ethernet-switch-ubridge.md index 14e8e46b2..45e5430c0 100644 --- a/docs/features/builtin-ethernet-switch-ubridge.md +++ b/docs/features/builtin-ethernet-switch-ubridge.md @@ -85,7 +85,11 @@ bridge start {node_id}-{port} ``` Captures and marker signals are applied via the existing `_ubridge_apply_filters` -and `_ubridge_apply_markers` helpers from `BaseNode`. +and `_ubridge_apply_markers` helpers from `BaseNode`. The controller therefore allows +packet filters and traffic-insight markers on switch links (including switch-to-switch): +`ethernet_switch` is a marker/filter-capable node type, and the compute API exposes the +matching NIO-update and per-marker endpoints. The `ethernet_hub` — still Dynamips-hosted, +no uBridge — remains excluded. ### `remove_nio(port_number)` diff --git a/docs/features/marker-traffic-insight.md b/docs/features/marker-traffic-insight.md index b275c02b7..ab71cbf65 100644 --- a/docs/features/marker-traffic-insight.md +++ b/docs/features/marker-traffic-insight.md @@ -97,8 +97,8 @@ IOU runs a single `IOL-BRIDGE` per node shared by every interface, so `bridge`+` identical across that node's links. uBridge keeps a separate filter list **per port (bay/unit)** within the bridge, so each interface gets its own `global-{name}` filter, its own pcap file, and its own `link=`. The shared bridge name is irrelevant to attribution. Other -capable node types (`qemu`, `docker`, `vpcs`, `cloud`) already use one bridge per link; `link` -applies uniformly to all of them. +capable node types (`qemu`, `docker`, `vpcs`, `cloud`, `ethernet_switch`) already use one +bridge per link (the switch's per-port relay); `link` applies uniformly to all of them. ## Direction @@ -137,7 +137,7 @@ pass `capture_node_id` on marker **create**: ``` The value must be one of the link's two endpoints and a marker-capable type (`vpcs`, `qemu`, -`docker`, `iou`, `dynamips`, `cloud`); any other id is rejected with `409`. Omit it to keep +`docker`, `iou`, `dynamips`, `cloud`, `ethernet_switch`); any other id is rejected with `409`. Omit it to keep the auto-pick. The chosen id is echoed back as `capture_node_id` in the marker entry and in each `MARK` signal's `node=`, so the Web UI always knows the observer regardless of who picked it. @@ -357,7 +357,9 @@ direction relative to the capture node; see [Direction](#direction). runs one `tcpdump -d` rather than *N*. (uBridge still runs `pcap_compile` itself at install time, so an invalid expression can never slip through.) - **Supported node types.** A marker needs a uBridge bridge: `vpcs`, `qemu`, `docker`, - `iou`, `dynamips`, `cloud` (one capable endpoint suffices). Types without a uBridge are + `iou`, `dynamips`, `cloud`, `ethernet_switch` (one capable endpoint suffices). The + `ethernet_switch` hosts markers on its per-port uBridge relays (brctl backend); the + `ethernet_hub` is still Dynamips-hosted and has no uBridge. Types without a uBridge are silently skipped by the inheritance fan-out. IOU uses one shared `IOL-BRIDGE` per node but keeps filters, pcap files, and `link=` ids per port, so multi-interface nodes are handled (see [Per-link attribution](#per-link-attribution)). diff --git a/gns3server/api/routes/compute/ethernet_switch_nodes.py b/gns3server/api/routes/compute/ethernet_switch_nodes.py index eaff681cb..d6d253ca9 100644 --- a/gns3server/api/routes/compute/ethernet_switch_nodes.py +++ b/gns3server/api/routes/compute/ethernet_switch_nodes.py @@ -192,6 +192,33 @@ async def create_ethernet_switch_nio( return nio.asdict() +@router.put( + "/{node_id}/adapters/{adapter_number}/ports/{port_number}/nio", + status_code=status.HTTP_201_CREATED, + response_model=schemas.UDPNIO, +) +async def update_ethernet_switch_nio( + *, + adapter_number: int = Path(..., ge=0, le=0), + port_number: int, + nio_data: schemas.UDPNIO, + node: EthernetSwitch = Depends(dep_node) +) -> schemas.UDPNIO: + """ + Update a NIO (Network Input/Output) on the node: re-apply the packet + filters and traffic-insight markers carried by the NIO onto the port's + uBridge relay. The adapter number on the switch is always 0. + """ + + nio = node.get_nio(port_number) + nio.filters.clear() + if nio_data.filters: + nio.filters = nio_data.filters + nio.markers = nio_data.markers or {} + await node.update_nio(port_number, nio) + return nio.asdict() + + @router.delete("/{node_id}/adapters/{adapter_number}/ports/{port_number}/nio", status_code=status.HTTP_204_NO_CONTENT) async def delete_ethernet_switch_nio( *, @@ -257,3 +284,75 @@ async def stream_pcap_file( nio = node.get_nio(port_number) stream = Builtin.instance().stream_pcap_file(nio, node.project.id) return StreamingResponse(stream, media_type="application/vnd.tcpdump.pcap") + + +@router.put("/{node_id}/markers/{marker_name}") +async def toggle_ethernet_switch_marker( + marker_name: str, + toggle_data: schemas.MarkerToggle, + node: EthernetSwitch = Depends(dep_node) +) -> dict: + """ + Toggle a marker filter on/off without an NIO rebuild (ubridge contract §3.2). + """ + + if not any(n == marker_name for (n, lid) in node._marker_filter_bridges): + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"Marker '{marker_name}' is not installed on this node", + ) + await node._ubridge_set_marker_filter_state(marker_name, toggle_data.enabled) + return {"marker_name": marker_name, "enabled": toggle_data.enabled} + + +@router.post("/{node_id}/markers/pause", status_code=status.HTTP_204_NO_CONTENT) +async def pause_ethernet_switch_markers(node: EthernetSwitch = Depends(dep_node)) -> None: + + await node._ubridge_marker_pause() + + +@router.post("/{node_id}/markers/resume", status_code=status.HTTP_204_NO_CONTENT) +async def resume_ethernet_switch_markers(node: EthernetSwitch = Depends(dep_node)) -> None: + + await node._ubridge_marker_resume() + + +@router.delete( + "/{node_id}/adapters/{adapter_number}/ports/{port_number}/markers/{marker_name}", + status_code=status.HTTP_204_NO_CONTENT, +) +async def delete_ethernet_switch_marker_capture( + *, + marker_name: str, + adapter_number: int = Path(..., ge=0, le=0), + port_number: int, + link_id: str = "", + node: EthernetSwitch = Depends(dep_node) +) -> None: + """ + Delete a marker's capture pcap (called by the controller when the marker is + removed) so the file is cleaned up even with the switch stopped. Also drops + the marker from the port NIO's cached spec so a switch restart won't + reinstall it (and recreate an empty pcap). The adapter number is always 0. + """ + + nio = node.get_nio(port_number) + await node.delete_marker_capture(marker_name, link_id, nio) + + +@router.put("/{node_id}/markers/{marker_name}/rebuild") +async def rebuild_ethernet_switch_marker( + marker_name: str, + rebuild_data: schemas.MarkerRebuild, + node: EthernetSwitch = Depends(dep_node) +) -> dict: + """ + Re-install a single marker filter with new BPF/tag/direction (delete + add, + no bridge reset) so sibling markers' pcaps stay open. + """ + + await node.rebuild_marker_filter( + marker_name, rebuild_data.link_id, rebuild_data.bpf, + rebuild_data.tag, rebuild_data.direction, rebuild_data.enabled, + ) + return {"marker_name": marker_name} diff --git a/gns3server/controller/link.py b/gns3server/controller/link.py index d27fe80e8..5f68e484b 100644 --- a/gns3server/controller/link.py +++ b/gns3server/controller/link.py @@ -624,6 +624,8 @@ class Link: "nat", "virtualbox", "docker", + # the brctl Ethernet switch applies filters on its per-port uBridge relays + "ethernet_switch", ): return node["node"] return None diff --git a/gns3server/controller/udp_link.py b/gns3server/controller/udp_link.py index 1184dad74..bc6cf2d52 100644 --- a/gns3server/controller/udp_link.py +++ b/gns3server/controller/udp_link.py @@ -24,12 +24,13 @@ from .link import Link, _UNSET from .node_types import BUILTIN_NODE_TYPES from gns3server.utils.packet_filter_validation import validate_bpf_syntax, FilterValidationError -# Node types without a uBridge bridge — a marker filter has nothing to attach to. # Node types that can host a marker (have a uBridge bridge to attach the # `mark` filter to). Mirrors _get_filter_node in link.py, minus "nat" -# (which has no uBridge). +# (which has no uBridge) and "ethernet_hub" (still Dynamips-hosted, no +# uBridge of its own). "ethernet_switch" hosts markers on the per-port +# uBridge relays of its brctl kernel-bridge backend. _MARKER_CAPABLE_TYPES = frozenset({ - "vpcs", "qemu", "docker", "iou", "dynamips", "cloud", + "vpcs", "qemu", "docker", "iou", "dynamips", "cloud", "ethernet_switch", }) @@ -234,7 +235,10 @@ class UDPLink(Link): self._link_data[0]["filters"] = node1_filters self._link_data[0]["markers"] = node1_markers self._link_data[0]["suspend"] = self._suspended - if node1.node_type not in ("ethernet_switch", "ethernet_hub"): + # The Ethernet hub is still Dynamips-hosted (no uBridge of its own and + # no PUT NIO route) — keep skipping its side. Every other node type, + # including the brctl Ethernet switch, re-applies via the NIO update. + if node1.node_type != "ethernet_hub": await node1.put( f"/adapters/{adapter_number1}/ports/{port_number1}/nio", data=self._link_data[0], timeout=120 ) @@ -244,7 +248,7 @@ class UDPLink(Link): self._link_data[1]["filters"] = node2_filters self._link_data[1]["markers"] = node2_markers self._link_data[1]["suspend"] = self._suspended - if node2.node_type not in ("ethernet_switch", "ethernet_hub"): + if node2.node_type != "ethernet_hub": await node2.put( f"/adapters/{adapter_number2}/ports/{port_number2}/nio", data=self._link_data[1], timeout=221 ) diff --git a/tests/api/routes/compute/test_ethernet_switch_nodes.py b/tests/api/routes/compute/test_ethernet_switch_nodes.py index 16677686b..af2a12ef1 100644 --- a/tests/api/routes/compute/test_ethernet_switch_nodes.py +++ b/tests/api/routes/compute/test_ethernet_switch_nodes.py @@ -382,6 +382,7 @@ class TestEthernetSwitchNodesRoutes: response = await compute_client.delete(url) assert response.status_code == status.HTTP_204_NO_CONTENT + # the port's relay bridge and TAP are released from the kernel bridge br = node._bridge_name tap = f"{br}-0" relay = f"{node.id}-0" @@ -390,6 +391,92 @@ class TestEthernetSwitchNodesRoutes: call(f"bridge delete {relay}"), ]) + async def test_ethernet_switch_update_nio( + self, + app: FastAPI, + compute_client: AsyncClient, + compute_project: Project, + ethernet_switch: dict + ) -> None: + + url = app.url_path_for( + "compute:create_ethernet_switch_nio", + project_id=ethernet_switch["project_id"], + node_id=ethernet_switch["node_id"], + adapter_number="0", + port_number="0" + ) + params = self._udp_params() + params["filters"] = {"delay": [10, 0]} + response = await compute_client.post(url, json=params) + assert response.status_code == status.HTTP_201_CREATED + + node = compute_project.get_node(ethernet_switch["node_id"]) + node._ubridge_send.reset_mock() + + params["filters"] = {"packet_loss": [10]} + params["markers"] = {} + url = app.url_path_for( + "compute:update_ethernet_switch_nio", + project_id=ethernet_switch["project_id"], + node_id=ethernet_switch["node_id"], + adapter_number="0", + port_number="0" + ) + response = await compute_client.put(url, json=params) + assert response.status_code == status.HTTP_201_CREATED + assert response.json()["filters"] == {"packet_loss": [10]} + + # update_nio re-applies the filters on the port's uBridge relay + relay = node._ubridge_bridge_name(0) + node._ubridge_send.assert_any_call(f"bridge reset_packet_filters {relay}") + + async def test_ethernet_switch_toggle_marker( + self, + app: FastAPI, + compute_client: AsyncClient, + compute_project: Project, + ethernet_switch: dict + ) -> None: + + # a marker installed via the NIO registers in the node's filter-bridge map + url = app.url_path_for( + "compute:create_ethernet_switch_nio", + project_id=ethernet_switch["project_id"], + node_id=ethernet_switch["node_id"], + adapter_number="0", + port_number="0" + ) + params = self._udp_params() + params["markers"] = {"icmp": {"bpf": "icmp", "link_id": "link-1", "enabled": True}} + response = await compute_client.post(url, json=params) + assert response.status_code == status.HTTP_201_CREATED + + node = compute_project.get_node(ethernet_switch["node_id"]) + node._ubridge_send.reset_mock() + + url = app.url_path_for( + "compute:toggle_ethernet_switch_marker", + project_id=ethernet_switch["project_id"], + node_id=ethernet_switch["node_id"], + marker_name="icmp" + ) + response = await compute_client.put(url, json={"enabled": False}) + assert response.status_code == status.HTTP_200_OK + assert response.json() == {"marker_name": "icmp", "enabled": False} + relay = node._ubridge_bridge_name(0) + node._ubridge_send.assert_any_call(f"bridge enable_packet_filter {relay} icmp off") + + # toggling an unknown marker is a 404 + url = app.url_path_for( + "compute:toggle_ethernet_switch_marker", + project_id=ethernet_switch["project_id"], + node_id=ethernet_switch["node_id"], + marker_name="nope" + ) + response = await compute_client.put(url, json={"enabled": True}) + assert response.status_code == status.HTTP_404_NOT_FOUND + async def test_ethernet_switch_start_capture( self, app: FastAPI, diff --git a/tests/controller/test_link.py b/tests/controller/test_link.py index 54d1db5ba..0aa29b219 100644 --- a/tests/controller/test_link.py +++ b/tests/controller/test_link.py @@ -378,9 +378,9 @@ async def test_available_filters(project, compute): link.create = AsyncioMagicMock() assert link.available_filters() == [] - # Ethernet switch is not supported should return 0 filters + # The brctl Ethernet switch hosts filters on its per-port uBridge relays await link.add_node(node1, 0, 4) - assert link.available_filters() == [] + assert len(link.available_filters()) > 0 node2 = Node(project, compute, "node2", node_type="vpcs") node2._ports = [EthernetPort("E0", 0, 0, 4)] diff --git a/tests/controller/test_marker.py b/tests/controller/test_marker.py index c5297aeff..7bacef1f2 100644 --- a/tests/controller/test_marker.py +++ b/tests/controller/test_marker.py @@ -56,20 +56,21 @@ def _valid_bpf(): return stack -async def _make_link(project, port_cls=EthernetPort): - """Build a created UDPLink between two VPCS nodes on a mocked compute. +async def _make_link(project, port_cls=EthernetPort, node_types=("vpcs", "vpcs")): + """Build a created UDPLink between two nodes on a mocked compute. ``port_cls`` defaults to EthernetPort; pass SerialPort for a serial link - (the link's link_type follows the port). + (the link's link_type follows the port). ``node_types`` overrides the + endpoint node types (e.g. a switch-to-switch link). """ compute = MagicMock() compute.id = "local" compute.host = "example.com" - node1 = Node(project, compute, "n1", node_type="vpcs") + node1 = Node(project, compute, "n1", node_type=node_types[0]) node1._ports = [port_cls("E0", 0, 0, 0)] - node2 = Node(project, compute, "n2", node_type="vpcs") + node2 = Node(project, compute, "n2", node_type=node_types[1]) node2._ports = [port_cls("E0", 0, 0, 1)] async def subnet(_other): @@ -112,6 +113,23 @@ async def test_start_marker_stores_entry(project): assert entry["highlight_duration"] == 800 assert entry["enabled"] is True assert entry["capture_node_id"] in {n["node"].id for n in link._nodes} + + +@pytest.mark.asyncio +async def test_start_marker_on_ethernet_switch_link(project): + """A switch-to-switch link can host a marker: the brctl Ethernet switch + runs a per-port uBridge relay the `mark` filter attaches to.""" + + with _valid_bpf(): + link = await _make_link(project, node_types=("ethernet_switch", "ethernet_switch")) + await link.start_marker("icmp", "icmp") + + entry = link.markers["icmp"] + switch_ids = {n["node"].id for n in link._nodes} + assert entry["capture_node_id"] in switch_ids + # the marker rides exactly one side's NIO and is pushed via update() + carrying = [d for d in link._link_data if "icmp" in d["markers"]] + assert len(carrying) == 1 assert "inherited_from" not in entry diff --git a/tests/controller/test_udp_link.py b/tests/controller/test_udp_link.py index b17120757..e5045cc36 100644 --- a/tests/controller/test_udp_link.py +++ b/tests/controller/test_udp_link.py @@ -451,6 +451,55 @@ async def test_update(project): }, timeout=120) +@pytest.mark.asyncio +async def test_update_ethernet_switch_nio(project): + """ + Link updates must reach an Ethernet switch endpoint: the brctl switch has + a PUT NIO route, so only the Dynamips-hosted hub side stays skipped. + """ + + compute1 = MagicMock() + + node_vpcs = Node(project, compute1, "node1", node_type="vpcs") + node_vpcs._ports = [EthernetPort("E0", 0, 0, 4)] + node_switch = Node(project, compute1, "node2", node_type="ethernet_switch") + node_switch._ports = [EthernetPort("E0", 0, 3, 1)] + + async def subnet_callback(compute2): + return ("192.168.1.1", "192.168.1.2") + + compute1.get_ip_on_same_subnet.side_effect = subnet_callback + + async def compute1_callback(path, data={}, **kwargs): + if "/ports/udp" in path: + response = MagicMock() + response.json = {"udp_port": 1024} + return response + + compute1.post.side_effect = compute1_callback + compute1.put = AsyncioMagicMock() + compute1.host = "example.com" + + link = UDPLink(project) + await link.add_node(node_vpcs, 0, 4) + await link.add_node(node_switch, 3, 1) + assert link.created + + await link.update_filters({"delay": [10, 0]}) + compute1.put.assert_any_call( + "/projects/{}/ethernet_switch/nodes/{}/adapters/3/ports/1/nio".format(project.id, node_switch.id), + data={ + "lport": 1024, + "rhost": "192.168.1.1", + "rport": 1024, + "type": "nio_udp", + "suspend": False, + "markers": {}, + "filters": {} + }, timeout=221 + ) + + @pytest.mark.asyncio async def test_update_suspend(project): compute1 = MagicMock()