marker: drive uBridge enabled/pause/resume in real time

Wire gns3-server to uBridge's real-time marker controls (contract
../ubridge/doc/gns3server-integration.md §3.2), in three layers:

A. enabled reaches uBridge — _markers_for_node no longer drops disabled
   markers controller-side; it carries `enabled` in the NIO spec. apply
   installs every marker then issues `enable_packet_filter … off` for the
   disabled ones (base_node bridge / iou iol_bridge; old-ubridge errors
   downgrade to a warning so a toggle can't break link create).

B. instant per-filter toggle — apply records name→bridge so a new
   `_ubridge_set_marker_filter_state` can flip a running filter with
   `enable_packet_filter on|off` (iou overrides for iol_bridge + bay/unit).
   Each marker-capable node type gains PUT /markers/{name}; update_marker
   short-circuits to it when only `enabled` changes (no NIO rebuild, no pcap
   flush), falling back to reset+reapply if the route is unavailable.

C. global pause/resume — `_ubridge_marker_pause/resume` send `marker pause`
   / `marker resume` direct to the hypervisor (pause stops signal+pcap,
   resume instant, sink retained). Six node-type routes add POST
   /markers/pause|resume; project.pause_all/resume_all_markers fan out to
   each capture node (deduped, best-effort); REST exposes
   POST /projects/{id}/markers/pause|resume.

Toggling enabled and pause/resume are now both instant — only marker create
or a bpf change still go through reset+reapply.
This commit is contained in:
YueGuobin 2026-08-02 16:19:20 +08:00
parent 7368e4a65b
commit 3d06c4e22f
No known key found for this signature in database
13 changed files with 404 additions and 6 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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 namebridge 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):
"""

View File

@ -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 <bridge> <bay> <unit> <name> 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):
"""

View File

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

View File

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

View File

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

View File

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