controller: allow markers and packet filters on Ethernet switch links

The brctl Ethernet switch runs a per-port uBridge relay, so it can host
the mark filter and packet filters like any uBridge-backed node. Add
ethernet_switch to _MARKER_CAPABLE_TYPES and _get_filter_node, narrow the
UDPLink.update() NIO-PUT skip down to the Dynamips-hosted ethernet_hub,
and expose the matching compute endpoints: PUT nio (filter/marker
reapply) plus the per-marker toggle/pause/resume/delete/rebuild routes.

The ethernet_hub keeps its exclusion: its routes still wire into the
Dynamips hub, which has no uBridge of its own.
This commit is contained in:
YueGuobin 2026-08-19 23:27:40 +08:00
parent 210103058f
commit 72dc10a669
No known key found for this signature in database
9 changed files with 282 additions and 17 deletions

View File

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

View File

@ -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=<id>`, 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)).

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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