YueGuobin c3c025ab2a
fix: resolve the code-review findings on the kernel datapath
Compute:
- Track the anchor that owns uBridge's single AF_PACKET capture slot: a
  second kernel port's capture is refused with a clear error (its flag
  rolled back), a failed start no longer wedges the port, and only the
  owning port's stop issues the process-wide `capture stop_kernel`
  (docker/qemu/iou/dynamips/switch, via the mixin's slot helpers).
- Unwind failed starts instead of leaking uBridge and the anchor TAPs:
  QEMU's stop body runs under try/finally, QEMU and IOU start the datapath
  inside the launch try, Dynamips undoes a half-attached port (the retry
  used to be short-circuited into a silent black hole), and Docker deletes
  the veth pair when the namespace move fails.
- Restore the relay filter guard on IOU (kernel-only filters raise
  NodeError, an uncompilable BPF line degrades to a warning), validate the
  eBPF/quota/window/bpf filter shapes before touching the bridge (short or
  non-numeric values surfaced as 500s after partial state), and refuse a
  second NIO on an occupied Docker/QEMU port (the guard IOU and Dynamips
  already had).
- Probe fixes: `_probe_bridge_tap` survives a failing uBridge start, and
  the probe caches key on ctime too, so a chmod/setcap remedy re-probes.

Controller:
- Parse GNS3_UNIX_SOCKET_NIO like the compute does (line-anchored, truthy
  value) and mirror the compute's class selection in kernel_anchor_type.
- Read partial capability reports defensively; make BridgeNIO.bridge
  required-but-nullable so an omitted key is a 422 rather than a KeyError.
- Strip the packet_loss correlation slot even when it is zero: the relay
  filter takes exactly one argument and rejected `packet_loss 10 0`.

Ethernet switch: skip ports whose absorbed anchor does not exist yet (the
join is deferred by design) and roll the ports_mapping back when the VLAN
reconcile fails, so a retry still diffs against the old mapping.

Agent: reject device config entries without a device_name instead of
silently dropping them (a malformed batch used to execute partially while
reporting success).

Also split the netem builder to clear C901 and drop the two per-file ruff
ignores the PR had added, keeping the remove-only policy.
2026-10-06 19:55:21 +08:00

1033 lines
47 KiB
Python

#!/usr/bin/env python
#
# Copyright (C) 2016 GNS3 Technologies Inc.
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
import asyncio
import logging
from gns3server.config import Config
from gns3server.utils.application_id import is_iol_runner_environment
from gns3server.utils.kernel_anchor import kernel_anchor_name, kernel_anchor_type, kernel_cascade_names
from gns3server.utils.packet_filter_validation import (
split_kernel_only_features,
validate_bpf_syntax,
)
from .compute import ComputeError
from .controller_error import ControllerError, ControllerNotFoundError
from .link import _UNSET, Link
from .node_types import BUILTIN_NODE_TYPES
# 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) 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",
"ethernet_switch",
}
)
log = logging.getLogger(__name__)
def _is_unix_socket_docker(node):
"""
Best-effort detection of vendor containers bridged through AF_UNIX
socket pairs with no anchor of their own: the generic
GNS3_UNIX_SOCKET_NIO knob. IOL runner containers also bridge through
unix sockets (their class forces the wiring), but their anchors are
persistent TAPs — they graduate through _kernel_endpoint_ready's
capability gate instead of this exclusion. When both markers are present
the IOL runner one wins the compute's class selection, so the exclusion
must not fire for it. The compute side independently rejects
kernel-datapath NIOs on anchor-less containers, so a missed detection
surfaces as a clearer-late error — except on the switch fast path, where
an absent anchor is deferred forever by design (a link to a stopped node
must not fail), making the miss a silently dead cable.
"""
environment = (node.properties or {}).get("environment") or ""
if is_iol_runner_environment(environment):
return False
for line in environment.splitlines():
line = line.strip().rstrip(",")
if line.startswith("GNS3_UNIX_SOCKET_NIO="):
# Mirror the compute's parsing exactly (VendorDockerVM): the
# marker must start the line, and only a truthy value enables
# the unix-socket wiring — with 0/false/no the container wires
# adapters the standard way and can carry a kernel link.
return line.split("=", 1)[1].strip().lower() in ("1", "true", "yes")
return False
class UDPLink(Link):
def __init__(self, project, link_id=None):
super().__init__(project, link_id=link_id)
self._created = False
self._link_data = []
# True when the link could not be created on the computes because one
# of its endpoints has a missing image. The NIO creation is retried
# once the image is resolved.
self._deferred = False
@property
def debug_link_data(self):
"""
Use for the debug exports
"""
return self._link_data
@property
def deferred(self):
"""Whether NIO creation is waiting for missing node images."""
return self._deferred
def _get_node_filters(self, node1, node2, kernel=None):
"""
Determine which node gets the active filters applied.
:param kernel: explicit datapath decision for the caller's context.
``_prepare`` must pass it: it clears ``_link_data`` (the source
of the ``kernel_datapath`` property) before rebuilding the NIO
specs, so deriving the datapath there would read the previous
run's. Callers operating on established link data (``update``)
derive it from the property.
:returns: Tuple of (node1_filters, node2_filters)
"""
# Kernel-datapath links implement suspend natively (interface carrier
# driven by the NIO suspend flag). The synthetic frequency_drop filter
# that get_active_filters() injects for suspended links is a
# relay-datapath emulation mechanism, so kernel links push the *real*
# filters straight from storage. Both endpoints receive them: each
# end's compute attaches one tc netem qdisc to its veth host end
# (egress = traffic entering that container), so every direction of
# the link is impaired exactly once — the same net effect as the
# relay, where both directions cross the single filtered bridge.
if kernel is None:
kernel = self.kernel_datapath
if kernel:
# Ethernet switch fast path: the link's tc impairments apply on
# the absorbed anchor, which is a single interface — exactly one
# end may own them, and that end is the switch (it owns the
# anchor's bridge membership and port VLANs). A cascade link is
# two interfaces (one veth end per switch), so it takes the
# normal two-sided treatment like any other kernel link.
switch = self._switch_endpoint()
both_switches = (
self._nodes[0]["node"].node_type == "ethernet_switch"
and self._nodes[1]["node"].node_type == "ethernet_switch"
)
if switch is not None and not both_switches:
return (self._filters, {}) if switch[0] == 0 else ({}, self._filters)
return self._filters, self._filters
# Relay datapath: the netem-extension filters (rate, reorder, gemodel…)
# have no relay equivalent. update_filters rejects them on created
# relay links, but a project loaded with such filters on a link that
# cannot be kernel-wired (cross-compute, non-docker node, relay-only
# filter mix…) reaches this point — drop what cannot run with a
# warning, exactly like invalid filters are dropped at load time.
relay_filters, dropped = split_kernel_only_features(self.get_active_filters())
if dropped:
log.warning(
"Link %s: dropping packet filter(s) %s — no kernel datapath available on this link (uBridge relay wiring)",
self._id,
", ".join(sorted(dropped)),
)
filter_node = self._get_filter_node()
return (
relay_filters if filter_node == node1 else {},
relay_filters if filter_node == node2 else {},
)
def _markers_for_node(self, node):
"""
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"),
"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
}
def _get_node_markers(self, node1, node2):
"""
Determine which node gets which markers applied.
:returns: Tuple of (node1_markers, node2_markers)
"""
return self._markers_for_node(node1), self._markers_for_node(node2)
def _kernel_datapath_eligible(self, node1, node2):
"""
Whether this link can be wired on the kernel datapath (the adapters'
host-side anchors enslaved into a per-link Linux bridge) instead of
the uBridge UDP relay. Every GNS3 filter type has a kernel equivalent
— netem for delay/packet_loss/corrupt and the netem extensions,
cls_bpf for bpf, the eBPF stateful classifier for frequency_drop — so
filters no longer disqualify the kernel path. Capture and markers are
served by uBridge's AF_PACKET modules on the anchor.
Every adapter owns an anchor, so the datapath is a runtime decision —
links attach to running nodes too: the compute side enslaves the
anchor (brctl) or attaches the relay to it (add_nio_ethernet) without
touching the interfaces the node itself uses. Both endpoints must be
on the same compute (there is no kernel link across hosts) and both
must be able to anchor: Docker adapters are born as veth pairs, and
IOL runner containers — whose guest leg is unix sockets, not a veth —
anchor on persistent TAPs reached through the bridge module's
swappable TAP leg (its own capability pair, see
_kernel_endpoint_ready). QEMU adapters are persistent TAPs, which
need uBridge's tap module —
asked of that compute, since an old uBridge leaves QEMU on the relay
datapath and cannot carry a kernel link at all. IOU's Ethernet
bays anchor on persistent TAPs too, bound to its IOL fabric
(``iol_bridge add_nio_tap``) — a separate capability, since it can
be present or absent independently of the tap module. Dynamips
Ethernet slot ports anchor on persistent TAPs the hypervisor opens
(the tap module again — the Dynamips binary itself creates nothing),
so it gates like QEMU. A kernel link is an Ethernet segment, so
non-Ethernet ports (IOU serial, Dynamips serial/ATM/POS adapters)
stay on the relay whatever the node's capabilities.
"""
if not Config.instance().settings.Server.enable_kernel_datapath:
return False
if node1.compute.id != node2.compute.id:
return False
if _is_unix_socket_docker(node1) or _is_unix_socket_docker(node2):
return False
for side in self._nodes:
port = side.get("port")
if port is not None and port.link_type != "ethernet":
return False
# Ethernet switch fast path: the switch absorbs the peer's anchor
# into its own kernel bridge, so the switch side needs no anchoring
# capability of its own (its bridge and brctl already exist) — only
# the peer must be able to anchor. Two switches cascade instead: a
# veth pair joins the two kernel bridges, each side enslaving its
# own end (a kernel interface belongs to exactly one bridge, so the
# pair is the only shape a bridge-to-bridge cable can take).
switch = self._switch_endpoint()
if switch is not None:
if node1.node_type == "ethernet_switch" and node2.node_type == "ethernet_switch":
return True
peer = node2 if switch[0] == 0 else node1
return self._kernel_endpoint_ready(peer)
return self._kernel_endpoint_ready(node1) and self._kernel_endpoint_ready(node2)
def _switch_endpoint(self):
"""
The Ethernet switch endpoint of this link as ``(index, node)``, or
None — the fast path where the switch owns the kernel bridge.
"""
for index, side in enumerate(self._nodes):
if side["node"].node_type == "ethernet_switch":
return index, side["node"]
return None
@staticmethod
def _kernel_endpoint_ready(node):
"""
Whether a node can anchor a kernel link on its compute.
"""
if node.node_type == "docker":
environment = (node.properties or {}).get("environment") or ""
if is_iol_runner_environment(environment):
# IOL runner containers anchor on persistent TAPs like IOU,
# through the bridge module's swappable TAP leg — both
# capabilities strict True: an unreported one (old uBridge,
# failed probe) keeps the link on the relay, where it always
# works.
capabilities = node.compute.capabilities or {}
return capabilities.get("ubridge_bridge_tap") is True and capabilities.get("ubridge_tap") is True
return True
capabilities = node.compute.capabilities or {}
if node.node_type == "qemu":
# Strict True: an unreported capability (old uBridge, failed
# probe) keeps the link on the relay, where it always works.
return capabilities.get("ubridge_tap") is True
if node.node_type == "iou":
return capabilities.get("ubridge_iol_tap") is True
if node.node_type == "dynamips":
# The anchors are uBridge-created TAPs the Dynamips hypervisor
# merely opens (nio create_tap) — the same tap-module capability
# QEMU needs, not a Dynamips-specific one.
return capabilities.get("ubridge_tap") is True
return False
async def _prepare(self):
"""
Local-only link setup: resolve peer addresses, reserve UDP ports and
build the two NIO tunnel specs (``self._link_data``). No NIO is sent to
the computes — the caller decides how to dispatch them (one-by-one via
:meth:`create`, or batched via the project-open bulk path).
:returns: list of two ``(node, adapter_number, port_number, nio_data)``
tuples, ready to be POSTed to each node's compute.
"""
# Start from a clean slate: reset() re-creates the link on the same
# object (delete() + create()), and _commit_nios()/update() always
# address indices 0/1 — appending onto the previous run's entries
# would re-commit the stale (already released) port pair and orphan
# the freshly allocated ports.
self._link_data = []
node1 = self._nodes[0]["node"]
adapter_number1 = self._nodes[0]["adapter_number"]
port_number1 = self._nodes[0]["port_number"]
node2 = self._nodes[1]["node"]
adapter_number2 = self._nodes[1]["adapter_number"]
port_number2 = self._nodes[1]["port_number"]
if self._kernel_datapath_eligible(node1, node2):
node1_filters, node2_filters = self._get_node_filters(node1, node2, kernel=True)
node1_markers, node2_markers = self._get_node_markers(node1, node2)
switch = self._switch_endpoint()
if switch is not None:
if node1.node_type == "ethernet_switch" and node2.node_type == "ethernet_switch":
# Switch-to-switch cascade: one veth pair joins the two
# kernel bridges, each side enslaving its own end into
# its own bridge and applying its own port mode to it —
# the kernel-native shape of a cable between two
# bridges (an interface belongs to exactly one bridge,
# so the pair is the only way to express it). The names
# derive from the link id (utils.kernel_anchor) and both
# sides carry the other end's name: each creates the
# pair when its end is missing (the two uBridge
# processes may race — one create wins, the other's
# failure is re-checked against the kernel), and either
# side's teardown may destroy it (deleting one veth end
# takes the whole pair), so no ordering between the two
# NIO posts is needed. Filters are two-sided (each end
# is its own interface, so each direction is impaired
# exactly once, like any other kernel link — not the
# single-sided absorbed-anchor case).
end0, end1 = kernel_cascade_names(self._id)
self._link_data = [
{
"type": "nio_anchor",
"anchor": end0,
"peer": end1,
"filters": node1_filters,
"markers": node1_markers,
"suspend": self._suspended,
},
{
"type": "nio_anchor",
"anchor": end1,
"peer": end0,
"filters": node2_filters,
"markers": node2_markers,
"suspend": self._suspended,
},
]
return [
(node1, adapter_number1, port_number1, self._link_data[0]),
(node2, adapter_number2, port_number2, self._link_data[1]),
]
# Ethernet switch fast path: the switch absorbs the peer's
# anchor into its own kernel bridge — no per-link bridge at
# all, so the peer end carries no bridge name (its anchor is
# bridged by the switch; it only keeps its own interface,
# markers it hosts and its carrier). The switch end gets the
# anchor's name to join: the name is the shared naming
# contract (utils.kernel_anchor), a pure function of the
# peer's node type/id/adapter/port — valid whether or not
# the peer is currently running, which is what makes links
# to stopped nodes wireable (the switch joins once the
# anchor exists, re-pushed by node_started).
index = switch[0]
peer_index = 1 - index
peer = self._nodes[peer_index]["node"]
peer_anchor = kernel_anchor_name(
kernel_anchor_type(peer.node_type, (peer.properties or {}).get("environment")),
peer.id,
self._nodes[peer_index]["adapter_number"],
self._nodes[peer_index]["port_number"],
)
peer_data = {
"type": "nio_bridge",
"bridge": None,
"filters": node1_filters if peer_index == 0 else node2_filters,
"markers": node1_markers if peer_index == 0 else node2_markers,
"suspend": self._suspended,
}
switch_data = {
"type": "nio_anchor",
"anchor": peer_anchor,
"filters": node1_filters if index == 0 else node2_filters,
"markers": node1_markers if index == 0 else node2_markers,
"suspend": self._suspended,
}
self._link_data = [switch_data, peer_data] if index == 0 else [peer_data, switch_data]
return [
(node1, adapter_number1, port_number1, self._link_data[0]),
(node2, adapter_number2, port_number2, self._link_data[1]),
]
# Kernel datapath: each endpoint enslaves its veth host end into
# a per-link Linux bridge. The name derives from the link id so
# both ends compute it independently (no coordinator) and crash
# leftovers sweep deterministically; 11 hex chars fill IFNAMSIZ
# (15), pushing name-collision probability to irrelevance.
# Markers ride the NIO like on the relay datapath, routed by
# capture node; they attach to the veth host end via uBridge's
# AF_PACKET marker module instead of a relay `mark` filter.
# Filters become one tc netem qdisc per veth host end (the netem
# surface and its extensions), plus cls_bpf match-drop (bpf) and
# the eBPF stateful classifier (frequency_drop, quota) — every
# GNS3 filter type has a kernel equivalent now.
bridge_name = "gns3" + self._id.replace("-", "")[:11]
self._link_data = [
{
"type": "nio_bridge",
"bridge": bridge_name,
"filters": node1_filters,
"markers": node1_markers,
"suspend": self._suspended,
},
{
"type": "nio_bridge",
"bridge": bridge_name,
"filters": node2_filters,
"markers": node2_markers,
"suspend": self._suspended,
},
]
return [
(node1, adapter_number1, port_number1, self._link_data[0]),
(node2, adapter_number2, port_number2, self._link_data[1]),
]
# Get an IP allowing communication between both host
try:
(node1_host, node2_host) = await node1.compute.get_ip_on_same_subnet(node2.compute)
except ValueError as e:
raise ControllerError(f"Cannot get an IP address on same subnet: {e}")
# Reserve a UDP port on both sides in parallel. Pre-allocated ports
# (used during batch project loading) are popped from memory; otherwise
# each side falls back to a single HTTP round-trip to its compute.
async def _allocate_port(compute):
port = self._project.pop_preallocated_udp_port(compute.id)
if port is not None:
return port
response = await compute.post(f"/projects/{self._project.id}/ports/udp")
return response.json["udp_port"]
self._node1_port, self._node2_port = await asyncio.gather(
_allocate_port(node1.compute), _allocate_port(node2.compute)
)
node1_filters, node2_filters = self._get_node_filters(node1, node2, kernel=False)
node1_markers, node2_markers = self._get_node_markers(node1, node2)
# Build the tunnel specs for both sides. Index 0 is always node1 so
# that update()/delete() keep addressing self._link_data[0]/[1].
self._link_data.append(
{
"lport": self._node1_port,
"rhost": node2_host,
"rport": self._node2_port,
"type": "nio_udp",
"filters": node1_filters,
"markers": node1_markers,
"suspend": self._suspended,
}
)
self._link_data.append(
{
"lport": self._node2_port,
"rhost": node1_host,
"rport": self._node1_port,
"type": "nio_udp",
"filters": node2_filters,
"markers": node2_markers,
"suspend": self._suspended,
}
)
return [
(node1, adapter_number1, port_number1, self._link_data[0]),
(node2, adapter_number2, port_number2, self._link_data[1]),
]
async def _commit_nios(self, entries):
"""
Send the two NIO tunnel POSTs in parallel and roll back on failure.
:param entries: the two ``(node, adapter_number, port_number, nio_data)``
tuples returned by :meth:`_prepare`.
"""
(node1, adapter_number1, port_number1, nio_data1), (node2, adapter_number2, port_number2, nio_data2) = entries
# The two ends are independent once the ports and peer addresses are
# known — each node talks to its own compute/uBridge with no shared
# lock between them — so the two POSTs overlap. That includes a
# cascade link: both sides create the veth pair when their end is
# missing (a race one side is designated to lose benignly), so
# neither post depends on the other's completion. If either fails,
# roll back whichever side succeeded before re-raising the first
# error.
results = await asyncio.gather(
node1.post(f"/adapters/{adapter_number1}/ports/{port_number1}/nio", data=nio_data1, timeout=120),
node2.post(f"/adapters/{adapter_number2}/ports/{port_number2}/nio", data=nio_data2, timeout=120),
return_exceptions=True,
)
errors = [result for result in results if isinstance(result, Exception)]
if errors:
cleanup = []
if not isinstance(results[0], Exception):
cleanup.append(node1.delete(f"/adapters/{adapter_number1}/ports/{port_number1}/nio", timeout=120))
if not isinstance(results[1], Exception):
cleanup.append(node2.delete(f"/adapters/{adapter_number2}/ports/{port_number2}/nio", timeout=120))
if cleanup:
await asyncio.gather(*cleanup, return_exceptions=True)
raise errors[0]
self._created = True
async def create(self):
"""
Create the link on the nodes (interactive path: prepare + commit).
"""
entries = await self._prepare()
await self._commit_nios(entries)
# New links automatically inherit every active project-level marker
# definition so the user doesn't have to reconfigure.
await self._project.apply_defs_to_new_link(self)
async def update(self):
"""
Update the link on the nodes
"""
if len(self._link_data) == 0:
return
node1 = self._nodes[0]["node"]
node2 = self._nodes[1]["node"]
node1_filters, node2_filters = self._get_node_filters(node1, node2)
node1_markers, node2_markers = self._get_node_markers(node1, node2)
adapter_number1 = self._nodes[0]["adapter_number"]
port_number1 = self._nodes[0]["port_number"]
self._link_data[0]["filters"] = node1_filters
self._link_data[0]["markers"] = node1_markers
self._link_data[0]["suspend"] = self._suspended
# 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
)
adapter_number2 = self._nodes[1]["adapter_number"]
port_number2 = self._nodes[1]["port_number"]
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 != "ethernet_hub":
await node2.put(
f"/adapters/{adapter_number2}/ports/{port_number2}/nio", data=self._link_data[1], timeout=120
)
async def delete(self):
"""
Delete the link and free the resources
"""
if not self._created:
if self._deferred:
# There is no NIO on the computes to delete, but the local
# back-references created while loading must be cleared.
await super().delete()
return
try:
node1 = self._nodes[0]["node"]
adapter_number1 = self._nodes[0]["adapter_number"]
port_number1 = self._nodes[0]["port_number"]
except IndexError:
return
try:
await node1.delete(f"/adapters/{adapter_number1}/ports/{port_number1}/nio", timeout=120)
# If the node is already deleted (user selected multiple element and delete all in the same time)
except ControllerNotFoundError:
pass
try:
node2 = self._nodes[1]["node"]
adapter_number2 = self._nodes[1]["adapter_number"]
port_number2 = self._nodes[1]["port_number"]
except IndexError:
return
try:
await node2.delete(f"/adapters/{adapter_number2}/ports/{port_number2}/nio", timeout=120)
# If the node is already deleted (user selected multiple element and delete all in the same time)
except ControllerNotFoundError:
pass
await super().delete()
async def reset(self):
"""
Reset the link.
"""
# recreate the link on the compute
await self.delete()
await self.create()
async def start_capture(self, data_link_type="DLT_EN10MB", capture_file_name=None, wireshark=False, jwt_token=None):
"""
Start capture on a link
"""
if not capture_file_name:
capture_file_name = self.default_capture_file_name()
self._capture_node = self._choose_capture_side()
data = {"capture_file_name": capture_file_name, "data_link_type": data_link_type}
await self._capture_node["node"].post(
"/adapters/{adapter_number}/ports/{port_number}/capture/start".format(
adapter_number=self._capture_node["adapter_number"], port_number=self._capture_node["port_number"]
),
data=data,
)
await super().start_capture(
data_link_type=data_link_type, capture_file_name=capture_file_name, wireshark=wireshark, jwt_token=jwt_token
)
async def stop_capture(self):
"""
Stop capture on a link
"""
if self._capture_node:
await self._capture_node["node"].post(
"/adapters/{adapter_number}/ports/{port_number}/capture/stop".format(
adapter_number=self._capture_node["adapter_number"], port_number=self._capture_node["port_number"]
)
)
self._capture_node = None
await super().stop_capture()
def _choose_capture_side(self):
"""
Run capture on the best candidate.
The ideal candidate is a node who on controller server and always
running (capture will not be cut off)
:returns: Node where the capture should run
"""
for node in self._nodes:
if (
node["node"].compute.id == "local"
and node["node"].node_type in BUILTIN_NODE_TYPES
and node["node"].status == "started"
):
return node
for node in self._nodes:
if node["node"].node_type in BUILTIN_NODE_TYPES and node["node"].status == "started":
return node
for node in self._nodes:
if node["node"].compute.id == "local" and node["node"].status == "started":
return node
for node in self._nodes:
if node["node"].node_type and node["node"].status == "started":
return node
raise ControllerError("Cannot capture because there is no running device on this link")
def _choose_marker_side(self):
"""
Pick the node that will host the marker, mirroring ``_get_filter_node``
in link.py. Only types with a uBridge bridge (``_MARKER_CAPABLE_TYPES``)
are eligible. A running node is preferred, but a stopped one is
accepted — like packet filters, the marker is stored on the NIO and
applied when the node starts.
"""
# Prefer started.
for node in self._nodes:
if node["node"].node_type in _MARKER_CAPABLE_TYPES and node["node"].status == "started":
return node
# Accept stopped but capable (marker rides NIO, applied at start).
for node in self._nodes:
if node["node"].node_type in _MARKER_CAPABLE_TYPES:
return node
raise ControllerError("Cannot add marker because no device on this link supports traffic insight")
def _node_by_id(self, node_id):
"""
Resolve a caller-chosen capture node by id, validating it is an
endpoint of this link and marker-capable. Used when the caller
(REST/MCP) explicitly pins the observer side instead of letting
``_choose_marker_side`` auto-pick.
:param node_id: node id (UUID or str) the caller requested
:returns: a ``self._nodes`` entry (node/adapter_number/port_number)
"""
target = str(node_id)
for node in self._nodes:
if str(node["node"].id) != target:
continue
if node["node"].node_type not in _MARKER_CAPABLE_TYPES:
raise ControllerError(
f"Node {node_id} ({node['node'].node_type}) cannot host a "
f"marker — no uBridge bridge to attach the filter to"
)
return node
raise ControllerNotFoundError(f"Node {node_id} is not an endpoint of link {self._id}")
async def node_updated(self, node):
"""
Called when a node member of the link is updated
"""
if self._capture_node and node == self._capture_node["node"] and node.status != "started":
await self.stop_capture()
# Marker clean-up is *not* done on node stop — markers are a persistent
# link-scoped feature that recovers via NIO on restart (see
# _ubridge_apply_markers in add_ubridge_udp_connection). The user
# explicitly deletes a marker via the REST API, and a marker is torn
# down automatically only when its link is deleted.
async def node_started(self, node):
"""
Called after a node of this link reached the started state.
The Ethernet switch fast path wires itself on the switch side, but a
peer's anchor only comes into existence when the peer starts (and a
peer restart can replace the interface under the same name — e.g.
QEMU recreates its TAPs at every start). The switch cannot observe
that, so the controller re-pushes its NIO: the switch completes a
join it had to defer (or re-joins an anchor that disappeared),
then re-applies the link state it owns.
"""
if not self._created:
return
switch = self._switch_endpoint()
if switch is None or not self.kernel_datapath:
return
both_switches = (
self._nodes[0]["node"].node_type == "ethernet_switch"
and self._nodes[1]["node"].node_type == "ethernet_switch"
)
# A cascade link has a switch on both ends and each end is its own
# veth half: either switch starting (recreating its bridge) can
# leave its own half unjoined, so both ends are re-pushed.
indices = [0, 1] if both_switches else [switch[0]]
for index in indices:
switch_node = self._nodes[index]["node"]
adapter_number = self._nodes[index]["adapter_number"]
port_number = self._nodes[index]["port_number"]
try:
await switch_node.put(
f"/adapters/{adapter_number}/ports/{port_number}/nio",
data=self._link_data[index],
timeout=120,
)
except (ComputeError, ControllerError):
# The link keeps its stored NIO; the next node start or link
# update retries. A failure here must not fail the node start.
log.warning("Could not re-push switch NIO for link %s after node start", self.id)
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,
dump=True,
memory_only=False,
):
"""
Attach a traffic-insight marker to this link.
State-only model (mirrors ``update_filters``): record the marker in
``_markers`` (with its capture-side node id for NIO routing), then push
via ``self.update()`` so it rides the NIO and is applied by
``_ubridge_apply_markers``. No dedicated uBridge round-trip — exactly
how packet filters are applied.
:param name: stable filter name — echoed in MARK signals + pcap identity
:param bpf: libpcap BPF expression
:param tag: optional correlation id
:param capture_node_id: optional explicit observer node. When set the
marker is pinned to that endpoint's uBridge (and ``direction`` is
interpreted from its perspective); validated by ``_node_by_id``.
Omitted = auto-pick via ``_choose_marker_side``. Ignored for
inherited markers (project defs are link-agnostic → always auto).
:param color: optional hex color for the Web UI (e.g. '#ff5722'); stored
with the link and persisted in the topology, never sent to uBridge
:param highlight_duration: optional UI-only hint (milliseconds) for how
long a match keeps the marker highlighted; stored, never sent to uBridge
:param inherited_from: def name when this marker is a project-level
inheritance copy; set automatically, never exposed to REST callers
"""
if name in self._markers:
raise ControllerError(f"Marker '{name}' already exists on link {self._id}")
# Validate the BPF only for private per-link markers. An inherited copy
# (``inherited_from`` set) fans out from a definition whose BPF was
# already validated once at create/update (and on project load), so
# re-validating per link would spawn one ``tcpdump -d`` per link for the
# same expression.
if not inherited_from:
result = validate_bpf_syntax(bpf)
if not result.get("valid"):
raise ControllerError(f"Invalid BPF expression: {result.get('error', 'unknown error')}")
if capture_node_id and not inherited_from:
marker_side = self._node_by_id(capture_node_id)
else:
marker_side = self._choose_marker_side()
marker_entry = {
"bpf": bpf,
"tag": tag,
"enabled": enabled,
"color": color,
"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
self._markers[name] = marker_entry
if memory_only:
# Project-open prepare / marker-def fan-out: only refresh the
# in-memory NIO specs so a later batch dispatch carries the new
# markers — no per-link update HTTP, notification or dump.
self._refresh_link_data()
return
if self._created:
await self.update()
self._project.emit_notification("link.updated", self.asdict())
# Bulk fan-out passes dump=False: N per-link topology writes on a
# 500-link project are the dominant cost — the caller dumps once after.
if dump:
self._project.dump()
def _refresh_link_data(self):
"""
Recompute the filters / markers / suspend fields of ``_link_data`` from
the current link state without pushing to computes. Used by the
memory-only marker path so a batch dispatch picks up the new markers.
"""
if len(self._link_data) < 2:
return
node1 = self._nodes[0]["node"]
node2 = self._nodes[1]["node"]
node1_filters, node2_filters = self._get_node_filters(node1, node2)
node1_markers, node2_markers = self._get_node_markers(node1, node2)
self._link_data[0]["filters"] = node1_filters
self._link_data[0]["markers"] = node1_markers
self._link_data[0]["suspend"] = self._suspended
self._link_data[1]["filters"] = node2_filters
self._link_data[1]["markers"] = node2_markers
self._link_data[1]["suspend"] = self._suspended
async def stop_marker(self, name, inherited=False, dump=True, memory_only=False):
"""
Remove a traffic-insight marker from this link.
Drop it from ``_markers`` and push via ``self.update()``: the NIO
reset+reapply in ``_ubridge_apply_filters``/``_ubridge_apply_markers``
drops it from uBridge. Mirrors how deleting a packet filter works.
:param name: filter name to remove
:param inherited: set by project-level def-delete to bypass the
inheritance guard (the project layer is the legitimate remover)
"""
if name not in self._markers:
raise ControllerNotFoundError(f"Marker '{name}' not found on link {self._id}")
if self._markers[name].get("inherited_from") and not inherited:
raise ControllerError(
f"Marker '{name}' is inherited from the project-level "
f"definition '{self._markers[name]['inherited_from']}'. "
"Delete or update it via the marker-definitions API instead."
)
capture_node_id = self._markers[name].get("capture_node_id")
del self._markers[name]
if memory_only:
# Project-level def-delete fan-out: marker is gone from _markers;
# refresh _link_data so the batch dispatch drops it from uBridge
# via full reapply. No per-link delete round-trip, notification or dump.
self._refresh_link_data()
return
# Remove the marker filter + its pcap on the capture node directly — NOT a
# full NIO reapply (which would reset_packet_filters and close/reopen every
# sibling marker's pcap). delete_packet_filter removes just this filter;
# the marker is already gone from _markers, so any later reapply (filter
# change, node restart) won't re-add it either.
if capture_node_id is not None:
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"].delete(
f"/adapters/{side['adapter_number']}/ports/{side['port_number']}/markers/{name}",
params={"link_id": self._id},
)
except Exception:
pass # best-effort: old compute without the route leaves the file
self._project.emit_notification("link.updated", self.asdict())
if dump:
self._project.dump()
async def update_marker(
self,
name,
bpf=None,
tag=None,
enabled=None,
direction=_UNSET,
color=None,
highlight_duration=None,
inherited=False,
dump=True,
memory_only=False,
):
"""
Update an existing marker's fields and push to uBridge fine-grained — no
full NIO reapply, so sibling markers' pcaps stay open. bpf/tag/direction
rebuild just this filter (delete + add); enabled is an instant toggle;
color/highlight_duration are UI-only (stored, never pushed).
:param name: filter name to update
:param bpf: new BPF expression (None = keep existing)
:param tag: new tag id (None = keep existing)
:param enabled: toggle (None = keep existing)
:param color: new hex color (None = keep existing)
:param highlight_duration: new UI highlight duration in ms (None = keep existing)
:param inherited: set by project-level sync to bypass the inheritance
guard (the project layer is the legitimate editor)
"""
marker_info = self._markers.get(name)
if not marker_info:
raise ControllerNotFoundError(f"Marker '{name}' not found on link {self._id}")
if marker_info.get("inherited_from") and not inherited:
raise ControllerError(
f"Marker '{name}' is inherited from the project-level "
f"definition '{marker_info['inherited_from']}'. "
"Update it via the marker-definitions API instead."
)
# Merge every changed field into the marker state first.
if bpf is not None and bpf != marker_info["bpf"]:
# An inherited marker is synced from a definition whose BPF was
# already validated at create/update (or load); re-validating per
# link is redundant. Private markers validate here as before.
if not inherited:
result = validate_bpf_syntax(bpf)
if not result.get("valid"):
raise ControllerError(f"Invalid BPF expression: {result.get('error', 'unknown error')}")
marker_info["bpf"] = bpf
if tag is not None:
marker_info["tag"] = tag
if enabled is not None:
marker_info["enabled"] = enabled
if color is not None:
marker_info["color"] = color
if highlight_duration is not None:
marker_info["highlight_duration"] = highlight_duration
if direction is not _UNSET:
marker_info["direction"] = direction # None = clear back to both directions
if memory_only:
# Project-level def sync fan-out: state is already merged into
# _markers; just refresh _link_data so the batch dispatch carries
# it. No per-link uBridge rebuild, notification or dump.
self._refresh_link_data()
return
# Push to uBridge fine-grained — NO full NIO reapply (which would
# reset_packet_filters and close/reopen every sibling marker's pcap):
# * bpf/tag/direction changed → rebuild just this filter (delete + add),
# reopening only this marker's pcap (expected, new BPF)
# * only enabled changed → instant toggle (enable_packet_filter)
# * only UI fields changed → nothing to push to uBridge
if self._created:
ubridge_rebuild = (bpf is not None) or (tag is not None) or (direction is not _UNSET)
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:
if ubridge_rebuild:
await side["node"].put(
f"/markers/{name}/rebuild",
data={
"bpf": marker_info["bpf"],
"tag": marker_info.get("tag"),
"direction": marker_info.get("direction"),
"enabled": marker_info.get("enabled", True),
"link_id": self._id,
},
)
elif enabled is not None:
await side["node"].put(f"/markers/{name}", data={"enabled": enabled})
except Exception:
# Old compute without the route / node down: state is already
# correct in _markers; the next NIO reapply converges uBridge.
pass
self._project.emit_notification("link.updated", self.asdict())
if dump:
self._project.dump()