diff --git a/gns3server/api/routes/compute/cloud_nodes.py b/gns3server/api/routes/compute/cloud_nodes.py index 42e691235..539c73a44 100644 --- a/gns3server/api/routes/compute/cloud_nodes.py +++ b/gns3server/api/routes/compute/cloud_nodes.py @@ -255,3 +255,37 @@ 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_cloud_marker( + marker_name: str, + toggle_data: schemas.MarkerToggle, + node: Cloud = Depends(dep_node) +) -> dict: + """ + Toggle a marker filter on/off without an NIO rebuild (ubridge contract §3.2). + """ + + 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_cloud_markers(node: Cloud = 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_cloud_markers(node: Cloud = Depends(dep_node)) -> None: + + await node._ubridge_marker_resume() diff --git a/gns3server/api/routes/compute/docker_nodes.py b/gns3server/api/routes/compute/docker_nodes.py index f08f9a497..a322bd56e 100644 --- a/gns3server/api/routes/compute/docker_nodes.py +++ b/gns3server/api/routes/compute/docker_nodes.py @@ -408,3 +408,40 @@ async def vnc_console_ws( async def reset_console(node: DockerVM = Depends(dep_node)) -> None: await node.reset_console() + + +@router.put( + "/{node_id}/markers/{marker_name}", + dependencies=[Depends(compute_authentication)] +) +async def toggle_docker_marker( + marker_name: str, + toggle_data: schemas.MarkerToggle, + node: DockerVM = Depends(dep_node) +) -> dict: + """ + Toggle a marker filter on/off without an NIO rebuild (ubridge contract §3.2). + """ + + 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, + dependencies=[Depends(compute_authentication)] +) +async def pause_docker_markers(node: DockerVM = Depends(dep_node)) -> None: + + await node._ubridge_marker_pause() + + +@router.post( + "/{node_id}/markers/resume", + status_code=status.HTTP_204_NO_CONTENT, + dependencies=[Depends(compute_authentication)] +) +async def resume_docker_markers(node: DockerVM = Depends(dep_node)) -> None: + + await node._ubridge_marker_resume() diff --git a/gns3server/api/routes/compute/dynamips_nodes.py b/gns3server/api/routes/compute/dynamips_nodes.py index 70d2b10c6..05c32e7a6 100644 --- a/gns3server/api/routes/compute/dynamips_nodes.py +++ b/gns3server/api/routes/compute/dynamips_nodes.py @@ -367,3 +367,40 @@ async def console_ws( async def reset_console(node: Router = Depends(dep_node)) -> None: await node.reset_console() + + +@router.put( + "/{node_id}/markers/{marker_name}", + dependencies=[Depends(compute_authentication)] +) +async def toggle_dynamips_marker( + marker_name: str, + toggle_data: schemas.MarkerToggle, + node: Router = Depends(dep_node) +) -> dict: + """ + Toggle a marker filter on/off without an NIO rebuild (ubridge contract §3.2). + """ + + 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, + dependencies=[Depends(compute_authentication)] +) +async def pause_dynamips_markers(node: Router = Depends(dep_node)) -> None: + + await node._ubridge_marker_pause() + + +@router.post( + "/{node_id}/markers/resume", + status_code=status.HTTP_204_NO_CONTENT, + dependencies=[Depends(compute_authentication)] +) +async def resume_dynamips_markers(node: Router = Depends(dep_node)) -> None: + + await node._ubridge_marker_resume() diff --git a/gns3server/api/routes/compute/iou_nodes.py b/gns3server/api/routes/compute/iou_nodes.py index 99544043d..e1c97819d 100644 --- a/gns3server/api/routes/compute/iou_nodes.py +++ b/gns3server/api/routes/compute/iou_nodes.py @@ -346,3 +346,40 @@ async def console_ws( async def reset_console(node: IOUVM = Depends(dep_node)) -> None: await node.reset_console() + + +@router.put( + "/{node_id}/markers/{marker_name}", + dependencies=[Depends(compute_authentication)] +) +async def toggle_iou_marker( + marker_name: str, + toggle_data: schemas.MarkerToggle, + node: IOUVM = Depends(dep_node) +) -> dict: + """ + Toggle a marker filter on/off without an NIO rebuild (ubridge contract §3.2). + """ + + 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, + dependencies=[Depends(compute_authentication)] +) +async def pause_iou_markers(node: IOUVM = Depends(dep_node)) -> None: + + await node._ubridge_marker_pause() + + +@router.post( + "/{node_id}/markers/resume", + status_code=status.HTTP_204_NO_CONTENT, + dependencies=[Depends(compute_authentication)] +) +async def resume_iou_markers(node: IOUVM = Depends(dep_node)) -> None: + + await node._ubridge_marker_resume() diff --git a/gns3server/api/routes/compute/qemu_nodes.py b/gns3server/api/routes/compute/qemu_nodes.py index 6582dc731..7dc93869d 100644 --- a/gns3server/api/routes/compute/qemu_nodes.py +++ b/gns3server/api/routes/compute/qemu_nodes.py @@ -438,3 +438,40 @@ async def vnc_console_ws( async def reset_console(node: QemuVM = Depends(dep_node)) -> None: await node.reset_console() + + +@router.put( + "/{node_id}/markers/{marker_name}", + dependencies=[Depends(compute_authentication)] +) +async def toggle_qemu_marker( + marker_name: str, + toggle_data: schemas.MarkerToggle, + node: QemuVM = Depends(dep_node) +) -> dict: + """ + Toggle a marker filter on/off without an NIO rebuild (ubridge contract §3.2). + """ + + 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, + dependencies=[Depends(compute_authentication)] +) +async def pause_qemu_markers(node: QemuVM = Depends(dep_node)) -> None: + + await node._ubridge_marker_pause() + + +@router.post( + "/{node_id}/markers/resume", + status_code=status.HTTP_204_NO_CONTENT, + dependencies=[Depends(compute_authentication)] +) +async def resume_qemu_markers(node: QemuVM = Depends(dep_node)) -> None: + + await node._ubridge_marker_resume() diff --git a/gns3server/api/routes/compute/vpcs_nodes.py b/gns3server/api/routes/compute/vpcs_nodes.py index 07c439972..fd478833a 100644 --- a/gns3server/api/routes/compute/vpcs_nodes.py +++ b/gns3server/api/routes/compute/vpcs_nodes.py @@ -345,3 +345,40 @@ async def console_ws( async def reset_console(node: VPCSVM = Depends(dep_node)) -> None: await node.reset_console() + + +@router.put( + "/{node_id}/markers/{marker_name}", + dependencies=[Depends(compute_authentication)] +) +async def toggle_vpcs_marker( + marker_name: str, + toggle_data: schemas.MarkerToggle, + node: VPCSVM = Depends(dep_node) +) -> dict: + """ + Toggle a marker filter on/off without an NIO rebuild (ubridge contract §3.2). + """ + + 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, + dependencies=[Depends(compute_authentication)] +) +async def pause_vpcs_markers(node: VPCSVM = Depends(dep_node)) -> None: + + await node._ubridge_marker_pause() + + +@router.post( + "/{node_id}/markers/resume", + status_code=status.HTTP_204_NO_CONTENT, + dependencies=[Depends(compute_authentication)] +) +async def resume_vpcs_markers(node: VPCSVM = Depends(dep_node)) -> None: + + await node._ubridge_marker_resume() diff --git a/gns3server/api/routes/controller/projects.py b/gns3server/api/routes/controller/projects.py index 33ad90902..107392bfa 100644 --- a/gns3server/api/routes/controller/projects.py +++ b/gns3server/api/routes/controller/projects.py @@ -219,6 +219,37 @@ def get_project_markers(project: Project = Depends(dep_project)) -> dict: return project.markers +@router.post( + "/{project_id}/markers/pause", + status_code=status.HTTP_204_NO_CONTENT, + dependencies=[Depends(has_privilege("Project.Modify"))] +) +async def pause_project_markers(project: Project = Depends(dep_project)) -> None: + """ + Pause marker signal+pcap emission project-wide (``marker pause`` on every + marker-hosting node's uBridge; resume is instant, sink retained). + + Required privilege: Project.Modify + """ + + await project.pause_all_markers() + + +@router.post( + "/{project_id}/markers/resume", + status_code=status.HTTP_204_NO_CONTENT, + dependencies=[Depends(has_privilege("Project.Modify"))] +) +async def resume_project_markers(project: Project = Depends(dep_project)) -> None: + """ + Resume marker signal+pcap emission project-wide. + + Required privilege: Project.Modify + """ + + await project.resume_all_markers() + + # --------------------------------------------------------------------------- # Project-level marker definitions (global rules inherited by every link) # --------------------------------------------------------------------------- diff --git a/gns3server/compute/base_node.py b/gns3server/compute/base_node.py index 9b9ede270..e1f9fa591 100644 --- a/gns3server/compute/base_node.py +++ b/gns3server/compute/base_node.py @@ -100,6 +100,9 @@ class BaseNode: self._internal_aux_port = None self._custom_adapters = [] self._ubridge_require_privileged_access = False + # marker filter name -> uBridge bridge_name (recorded at apply time so + # _ubridge_set_marker_filter_state can toggle on/off without an NIO rebuild). + self._marker_filter_bridges = {} if self._console is not None: # use a previously allocated console port @@ -1163,9 +1166,63 @@ class BaseNode: self.project.emit("log.warning", {"message": message}) continue raise + # A disabled marker is installed but turned off (a paused tap), not + # dropped — so the UI can flip it back on instantly with + # enable_packet_filter, no NIO rebuild (ubridge contract §3.2). + if not spec.get("enabled", True): + try: + await self._ubridge_send(f"bridge enable_packet_filter {bridge_name} {name} off") + except UbridgeError as e: + # Old ubridge without enable_packet_filter: leave it installed + # (on) rather than fail the whole link/marker apply. + log.warning(f"Could not turn marker '{name}' off on {bridge_name}: {e}") manager.register( str(self.project.id), self._id, name, link_id, tag ) + # Remember which bridge hosts this filter so an instant on/off toggle + # (no NIO rebuild) can resolve it by name alone. + self._marker_filter_bridges[name] = bridge_name + + async def _ubridge_set_marker_filter_state(self, name, enabled): + """ + Toggle an installed marker filter on/off with a single uBridge command + (``bridge enable_packet_filter … on|off``) — no NIO reset/reapply, so the + pcap identity and emitted counter are preserved (ubridge contract §3.2). + The bridge is resolved from the name→bridge map populated at apply time; + IOU overrides this for its ``iol_bridge`` command shape. + + :param name: marker filter name + :param enabled: True = on (signal+pcap), False = off (paused tap) + """ + + bridge_name = self._marker_filter_bridges.get(name) + if not bridge_name: + raise UbridgeError(f"Marker '{name}' is not installed on this node") + state = "on" if enabled else "off" + await self._ubridge_send(f"bridge enable_packet_filter {bridge_name} {name} {state}") + + async def _ubridge_marker_pause(self): + """ + Pause all marker signal+pcap emission on this node's uBridge + (``marker pause``). Keeps the sink open so ``resume`` is instant. Safe + on old ubridge builds (the error is downgraded to a warning). Called by + the project-level pause fan-out. + """ + + if self._ubridge_hypervisor: + try: + await self._ubridge_hypervisor.send("marker pause") + except UbridgeError as e: + log.warning(f"Could not pause markers on node {self._id}: {e}") + + async def _ubridge_marker_resume(self): + """Resume marker signal+pcap emission (``marker resume``).""" + + if self._ubridge_hypervisor: + try: + await self._ubridge_hypervisor.send("marker resume") + except UbridgeError as e: + log.warning(f"Could not resume markers on node {self._id}: {e}") async def _add_ubridge_ethernet_connection(self, bridge_name, ethernet_interface, block_host_traffic=False): """ diff --git a/gns3server/compute/iou/iou_vm.py b/gns3server/compute/iou/iou_vm.py index f2936f044..33a4e6c4c 100644 --- a/gns3server/compute/iou/iou_vm.py +++ b/gns3server/compute/iou/iou_vm.py @@ -1321,9 +1321,25 @@ class IOUVM(BaseNode): self.project.emit("log.warning", {"message": message}) continue raise + if not spec.get("enabled", True): + try: + await self._ubridge_send(f"iol_bridge enable_packet_filter {location} {name} off") + except UbridgeError as e: + log.warning(f"Could not turn marker '{name}' off on {location}: {e}") manager.register( str(self.project.id), self._id, name, link_id, tag ) + # Record name -> location (bridge bay unit) for instant toggle. + self._marker_filter_bridges[name] = location + + async def _ubridge_set_marker_filter_state(self, name, enabled): + """IOU override: toggle via ``iol_bridge enable_packet_filter on|off``.""" + + location = self._marker_filter_bridges.get(name) + if not location: + raise UbridgeError(f"Marker '{name}' is not installed on this node") + state = "on" if enabled else "off" + await self._ubridge_send(f"iol_bridge enable_packet_filter {location} {name} {state}") async def adapter_remove_nio_binding(self, adapter_number, port_number): """ diff --git a/gns3server/controller/project.py b/gns3server/controller/project.py index aa1cbd82d..7c68f3eed 100644 --- a/gns3server/controller/project.py +++ b/gns3server/controller/project.py @@ -924,6 +924,41 @@ class Project: } return result + async def pause_all_markers(self): + """ + Pause marker signal+pcap emission on every node hosting a marker + (``marker pause`` per capture node's uBridge). Node-deduplicated and + best-effort: a node hosting markers on several links is paused once, + and a node that is down or running an old compute is skipped. + """ + + seen = set() + for link in list(self._links.values()): + for info in link.markers.values(): + node_id = info.get("capture_node_id") + if not node_id or node_id in seen: + continue + seen.add(node_id) + try: + await self.get_node(node_id).post("/markers/pause") + except Exception: + pass + + async def resume_all_markers(self): + """Resume marker signal+pcap emission on every marker-hosting node.""" + + seen = set() + for link in list(self._links.values()): + for info in link.markers.values(): + node_id = info.get("capture_node_id") + if not node_id or node_id in seen: + continue + seen.add(node_id) + try: + await self.get_node(node_id).post("/markers/resume") + except Exception: + pass + @property def marker_definitions(self): """ diff --git a/gns3server/controller/udp_link.py b/gns3server/controller/udp_link.py index e3764e317..b71bdf291 100644 --- a/gns3server/controller/udp_link.py +++ b/gns3server/controller/udp_link.py @@ -57,14 +57,17 @@ class UDPLink(Link): def _markers_for_node(self, node): """ - Marker specs (name -> {bpf, tag, link_id}) for the markers whose capture - side is ``node`` and that are enabled. Routed by capture_node_id so a - marker only rides the NIO of the node whose uBridge will host it. + 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. """ return { - name: {"bpf": m["bpf"], "tag": m.get("tag"), "link_id": self._id, "direction": m.get("direction")} + name: {"bpf": m["bpf"], "tag": m.get("tag"), "link_id": self._id, + "direction": m.get("direction"), "enabled": m.get("enabled", True)} for name, m in self._markers.items() - if m.get("enabled", True) and m.get("capture_node_id") == node.id + if m.get("capture_node_id") == node.id } def _get_node_markers(self, node1, node2): @@ -457,6 +460,33 @@ class UDPLink(Link): "Update it via the marker-definitions API instead." ) + # Instant toggle: when only `enabled` changes, send a single + # enable_packet_filter on|off to the capture node instead of rebuilding + # the whole NIO (no pcap flush, emitted counter preserved). Falls through + # to the full reset+reapply below if the compute route is unavailable. + only_enabled = ( + enabled is not None + and bpf is None + and tag is None + and direction is _UNSET + and color is None + and highlight_duration is None + ) + if only_enabled and self._created and not marker_info.get("inherited_from"): + capture_node_id = marker_info.get("capture_node_id") + side = next((s for s in self._nodes if str(s["node"].id) == str(capture_node_id)), None) + if side is not None: + try: + await side["node"].put(f"/markers/{name}", data={"enabled": enabled}) + marker_info["enabled"] = enabled + self._project.emit_notification("link.updated", self.asdict()) + self._project.dump() + return + except Exception: + # Old compute without the toggle route / node down: fall + # through to the full NIO reset+reapply below. + pass + if bpf is not None and bpf != marker_info["bpf"]: result = validate_bpf_syntax(bpf) if not result.get("valid"): diff --git a/gns3server/schemas/__init__.py b/gns3server/schemas/__init__.py index 7519639bc..7c8b9cab2 100644 --- a/gns3server/schemas/__init__.py +++ b/gns3server/schemas/__init__.py @@ -91,7 +91,7 @@ from .controller.templates.dynamips_templates import ( ) # Compute schemas -from .compute.nios import UDPNIO, TAPNIO, EthernetNIO +from .compute.nios import UDPNIO, TAPNIO, EthernetNIO, MarkerToggle from .compute.atm_switch_nodes import ATMSwitchCreate, ATMSwitchUpdate, ATMSwitch from .compute.cloud_nodes import CloudCreate, CloudUpdate, Cloud from .compute.docker_nodes import DockerCreate, DockerUpdate, Docker diff --git a/gns3server/schemas/compute/nios.py b/gns3server/schemas/compute/nios.py index f5cf5f073..f9a7ce7e6 100644 --- a/gns3server/schemas/compute/nios.py +++ b/gns3server/schemas/compute/nios.py @@ -65,3 +65,13 @@ class TAPNIO(BaseModel): type: TAPNIOType tap_device: str = Field(..., description="TAP device name e.g. tap0") + + +class MarkerToggle(BaseModel): + """ + Body for the per-marker enable/disable toggle endpoint: flips a running + uBridge marker filter with ``enable_packet_filter on|off`` (no NIO rebuild, + so the pcap identity and emitted counter are preserved). + """ + + enabled: bool