mirror of
https://github.com/GNS3/gns3-server.git
synced 2026-10-12 13:35:31 +03:00
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.
1033 lines
47 KiB
Python
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()
|