mirror of
https://github.com/GNS3/gns3-server.git
synced 2026-11-05 23:01:15 +02:00
Merge remote-tracking branch 'origin/3.1' into 3.1
This commit is contained in:
63 files changed
+3685
-382
No files matched your search
@@ -356,6 +356,7 @@ class BaseManager:
|
||||
raise ComputeError(f"Could not create an UDP connection to {rhost}:{rport}: {e}")
|
||||
nio = NIOUDP(lport, rhost, rport)
|
||||
nio.filters = nio_settings.get("filters", {})
|
||||
nio.markers = nio_settings.get("markers", {})
|
||||
nio.suspend = nio_settings.get("suspend", False)
|
||||
elif nio_settings["type"] == "nio_tap":
|
||||
tap_device = nio_settings["tap_device"]
|
||||
|
||||
@@ -935,9 +935,46 @@ class BaseNode:
|
||||
f"Hypervisor {self._ubridge_hypervisor.host}:{self._ubridge_hypervisor.port} has successfully started"
|
||||
)
|
||||
await self._ubridge_hypervisor.connect()
|
||||
# Tell this uBridge where to send MARK signals and which node id to
|
||||
# tag them with. Marker is opt-in and inert until a `mark` filter is
|
||||
# added, so this never disturbs the data plane.
|
||||
await self._ubridge_configure_marker_sink()
|
||||
# save if privileged are required in case uBridge needs to be restarted in self._ubridge_send()
|
||||
self._ubridge_require_privileged_access = require_privileged_access
|
||||
|
||||
async def _ubridge_configure_marker_sink(self):
|
||||
"""
|
||||
Point this node's uBridge at the compute's marker UDP sink and tag its
|
||||
signals with this node's id. Safe to call before any marker filter
|
||||
exists — uBridge stays inert until a ``mark`` filter is configured.
|
||||
|
||||
Old uBridge builds without the marker module are tolerated: the failure
|
||||
is downgraded to a warning so node start is not blocked by an opt-in
|
||||
observability feature.
|
||||
"""
|
||||
|
||||
from gns3server.compute.marker.marker_manager import MarkerManager
|
||||
|
||||
manager = MarkerManager.instance()
|
||||
if not manager.running or not manager.host or not manager.port:
|
||||
return
|
||||
if self._ubridge_hypervisor is None:
|
||||
return
|
||||
try:
|
||||
# Talk to the hypervisor directly, NOT via _ubridge_send: this runs
|
||||
# inside _start_ubridge, which is reached THROUGH _ubridge_send when
|
||||
# uBridge starts lazily (e.g. linking a stopped node). _ubridge_send's
|
||||
# lock is non-reentrant, so calling it again here would deadlock on
|
||||
# the held ___ubridge_send_lock. uBridge is already running and
|
||||
# connected at this point, so the raw hypervisor send is safe.
|
||||
await self._ubridge_hypervisor.send(f"marker sink {manager.host} {manager.port}")
|
||||
await self._ubridge_hypervisor.send(f"marker node {self._id}")
|
||||
except UbridgeError:
|
||||
log.warning(
|
||||
"uBridge does not support the marker module; traffic insight disabled for node %r",
|
||||
self.name,
|
||||
)
|
||||
|
||||
async def _stop_ubridge(self):
|
||||
"""
|
||||
Stops uBridge.
|
||||
@@ -983,10 +1020,12 @@ class BaseNode:
|
||||
|
||||
await self._ubridge_send(f"bridge start {bridge_name}")
|
||||
await self._ubridge_apply_filters(bridge_name, destination_nio.filters)
|
||||
await self._ubridge_apply_markers(bridge_name, destination_nio)
|
||||
|
||||
async def update_ubridge_udp_connection(self, bridge_name, source_nio, destination_nio):
|
||||
if destination_nio:
|
||||
await self._ubridge_apply_filters(bridge_name, destination_nio.filters)
|
||||
await self._ubridge_apply_markers(bridge_name, destination_nio)
|
||||
|
||||
async def ubridge_delete_bridge(self, name):
|
||||
"""
|
||||
@@ -1042,6 +1081,86 @@ class BaseNode:
|
||||
)
|
||||
i += 1
|
||||
|
||||
async def _ubridge_add_marker_filter(self, bridge_name, name, bpf, pcap_path, tag=None, link_id=None):
|
||||
"""
|
||||
Attach a `mark` packet filter to a uBridge bridge for traffic insight.
|
||||
|
||||
On BPF match uBridge (a) emits a UDP MARK signal to the configured sink
|
||||
and (b) appends the packet to ``pcap_path``. Unlike the impairment
|
||||
filters, this is an observability tap: it never drops or alters traffic,
|
||||
and it is added/removed on its own (not via reset_packet_filters) so the
|
||||
pcap is not closed/reopened on unrelated filter changes.
|
||||
|
||||
:param bridge_name: uBridge bridge carrying the link's traffic
|
||||
:param name: stable, gns3server-chosen filter name (pcap identity + echoed in signals)
|
||||
:param bpf: libpcap BPF expression
|
||||
:param pcap_path: absolute path ubridge appends matched packets to
|
||||
:param tag: optional correlation id echoed in MARK signals
|
||||
"""
|
||||
|
||||
# mark <bpf> [tag <id>] [pcap <path>] — tag/pcap keyword pairs, any order.
|
||||
# name travels from the controller REST layer (MarkerCreate schema) but is
|
||||
# validated here too as defense-in-depth against hand-edited topology files.
|
||||
# Note: "global-*" names are legitimate here — they come from project-level
|
||||
# marker definitions (inherit_marker). The prefix is only forbidden at the
|
||||
# user-facing schema layer, not at the uBridge boundary.
|
||||
_MARKER_NAME_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_.-]*$")
|
||||
if not _MARKER_NAME_RE.match(name):
|
||||
raise UbridgeError(f"Invalid marker name: {name!r}")
|
||||
cmd = 'bridge add_packet_filter {bridge} {name} mark "{bpf}"'.format(
|
||||
bridge=bridge_name, name=name, bpf=bpf
|
||||
)
|
||||
if tag is not None:
|
||||
cmd += f" tag {tag}"
|
||||
# Per-link attribution (contract §3.2): when one ubridge bridge serves
|
||||
# several GNS3 links (e.g. IOU's per-node bridge), bridge+filter collide,
|
||||
# so the link id is the only way to tell signals — and pcap files — apart.
|
||||
if link_id:
|
||||
cmd += f" link {link_id}"
|
||||
cmd += ' pcap "{path}"'.format(path=pcap_path)
|
||||
# Let BPF compile errors propagate — the marker is the user's intent, so a
|
||||
# bad expression must surface instead of being silently dropped.
|
||||
await self._ubridge_send(cmd)
|
||||
|
||||
async def _ubridge_apply_markers(self, bridge_name, nio):
|
||||
"""
|
||||
(Re-)apply every traffic-insight marker carried by *nio* to the uBridge
|
||||
bridge *bridge_name*. Called from ``add_ubridge_udp_connection`` (bridge
|
||||
creation / node restart) and ``update_ubridge_udp_connection`` (NIO update
|
||||
— the preceding ``_ubridge_apply_filters`` has already issued
|
||||
``reset_packet_filters``, so we must re-add markers to survive the reset).
|
||||
"""
|
||||
from gns3server.compute.marker.marker_manager import MarkerManager
|
||||
|
||||
markers = nio.markers if hasattr(nio, 'markers') else {}
|
||||
if not markers:
|
||||
return
|
||||
|
||||
manager = MarkerManager.instance()
|
||||
markers_dir = self.project.markers_working_directory()
|
||||
for name, spec in markers.items():
|
||||
bpf = spec.get("bpf", "")
|
||||
tag = spec.get("tag")
|
||||
link_id = spec.get("link_id", "")
|
||||
pcap_path = os.path.join(
|
||||
markers_dir, f"{self._id}_{link_id}_{name}.pcap"
|
||||
)
|
||||
try:
|
||||
await self._ubridge_add_marker_filter(bridge_name, name, bpf, pcap_path, tag, link_id)
|
||||
except UbridgeError as e:
|
||||
# Swallow BPF compile errors (warn + skip) so a single bad
|
||||
# expression can't break link creation / node restart — mirrors
|
||||
# _ubridge_apply_filters, which does the same for packet filters.
|
||||
if "syntax error" in str(e).lower() or "compile filter" in str(e).lower():
|
||||
message = f"Warning: ignoring marker '{name}' due to BPF syntax error: {e}"
|
||||
log.warning(message)
|
||||
self.project.emit("log.warning", {"message": message})
|
||||
continue
|
||||
raise
|
||||
manager.register(
|
||||
str(self.project.id), self._id, name, link_id, tag
|
||||
)
|
||||
|
||||
async def _add_ubridge_ethernet_connection(self, bridge_name, ethernet_interface, block_host_traffic=False):
|
||||
"""
|
||||
Creates a connection with an Ethernet interface in uBridge.
|
||||
|
||||
@@ -82,6 +82,9 @@ class Cloud(BaseNode):
|
||||
host_interfaces = []
|
||||
network_interfaces = gns3server.utils.interfaces.interfaces()
|
||||
for interface in network_interfaces:
|
||||
# Hide GNS3 internal bridges (e.g. EthernetSwitch kernel bridges)
|
||||
if interface["name"].lower().startswith("gns3"):
|
||||
continue
|
||||
host_interfaces.append(
|
||||
{
|
||||
"name": interface["name"],
|
||||
@@ -312,6 +315,7 @@ class Cloud(BaseNode):
|
||||
)
|
||||
|
||||
await self._ubridge_apply_filters(bridge_name, nio.filters)
|
||||
await self._ubridge_apply_markers(bridge_name, nio)
|
||||
if port_info["type"] in ("ethernet", "tap"):
|
||||
|
||||
if not self.manager.has_privileged_access(self.ubridge_path):
|
||||
@@ -452,6 +456,7 @@ class Cloud(BaseNode):
|
||||
bridge_name = f"{self._id}-{port_number}"
|
||||
if self._ubridge_hypervisor and self._ubridge_hypervisor.is_running():
|
||||
await self._ubridge_apply_filters(bridge_name, nio.filters)
|
||||
await self._ubridge_apply_markers(bridge_name, nio)
|
||||
|
||||
async def _delete_ubridge_connection(self, port_number):
|
||||
"""
|
||||
|
||||
@@ -14,14 +14,45 @@
|
||||
# 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
|
||||
"""
|
||||
Ethernet switch backed by a Linux kernel bridge driven through uBridge's
|
||||
``brctl`` module.
|
||||
|
||||
The historical GNS3 Ethernet switch was an emulated L2 device inside Dynamips
|
||||
(``ethsw``). This implementation replaces it with a *real* Linux kernel bridge:
|
||||
one bridge per switch node, managed over uBridge's hypervisor socket. Each
|
||||
switch port is a persistent TAP that plays two roles at once -- uBridge holds
|
||||
its file descriptor as a ``nio_tap`` relay endpoint, and the same TAP is
|
||||
enslaved to the kernel bridge as a port. This dual-role TAP is exactly the
|
||||
pattern the Cloud node already uses for host bridges (see
|
||||
``cloud.py::_add_linux_ethernet``).
|
||||
|
||||
Data path (UDP link mode)::
|
||||
|
||||
peer --UDP-- ubridge[nio_udp <-> nio_tap(tap)] --tap-- kernel bridge --tap-- ... (other ports)
|
||||
|
||||
The kernel bridge performs MAC learning/forwarding and VLAN filtering; uBridge
|
||||
is only the per-port UDP transport (uBridge is strictly a 2-NIO pipe, it cannot
|
||||
be the switch). ESW ``access``/``dot1q``/``qinq`` port modes are composed from
|
||||
the ``brctl`` VLAN primitives here -- see ``_apply_port_vlan``.
|
||||
"""
|
||||
|
||||
from ...base_node import BaseNode
|
||||
from ...nios.nio_udp import NIOUDP
|
||||
from ...error import NodeError
|
||||
from gns3server.compute.ubridge.ubridge_error import UbridgeError
|
||||
|
||||
import logging
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
# VLAN ethertypes the Linux kernel bridge can realise. ``brctl setvlanproto``
|
||||
# accepts only 0x8100 (802.1Q) and 0x88a8 (802.1ad). The GNS3 schema also allows
|
||||
# the legacy 0x9100/0x9200 QinQ ethertypes; the kernel bridge cannot do those, so
|
||||
# configuring them on a qinq port is rejected.
|
||||
_SUPPORTED_VLAN_ETHERTYPE = {"0x8100", "0x88a8"}
|
||||
_QINQ_ETHERTYPE = "0x88a8"
|
||||
|
||||
|
||||
class EthernetSwitch(BaseNode):
|
||||
|
||||
@@ -32,11 +63,101 @@ class EthernetSwitch(BaseNode):
|
||||
:param node_id: Node identifier
|
||||
:param project: Project instance
|
||||
:param manager: Parent VM Manager
|
||||
:param ports: initial switch ports
|
||||
"""
|
||||
|
||||
def __init__(self, name, node_id, project, manager):
|
||||
def __init__(self, name, node_id, project, manager, console=None, console_type=None, ports=None):
|
||||
|
||||
super().__init__(name, node_id, project, manager)
|
||||
super().__init__(name, node_id, project, manager, console=console, console_type=console_type or "none")
|
||||
# The switch has no console; ``console_type="none"`` makes BaseNode skip
|
||||
# reserving a TCP console port entirely.
|
||||
self._ubridge_require_privileged_access = True
|
||||
|
||||
self._nios = {}
|
||||
self._tap_by_port = {} # port_number -> kernel TAP enslaved to the bridge
|
||||
self._bridge_name = None # kernel bridge interface name (allocated on start)
|
||||
self._bridge_created = False
|
||||
self._bridge_proto_set = False # whether ``brctl setvlanproto`` has been applied
|
||||
# Idempotency flag for start(). Decoupled from ``status`` so the node can
|
||||
# report "started" (always-on, like the ESW) while ``duplicate_node`` still
|
||||
# sees status "stopped" and refuses only genuinely running stateful nodes.
|
||||
self._started = False
|
||||
|
||||
if ports is None:
|
||||
# 8 access ports in VLAN 1 by default, matching the historical ESW.
|
||||
self._ports_mapping = []
|
||||
for port_number in range(0, 8):
|
||||
self._ports_mapping.append(
|
||||
{"port_number": port_number, "name": f"Ethernet{port_number}", "type": "access", "vlan": 1}
|
||||
)
|
||||
else:
|
||||
self._ports_mapping = self._normalize_ports(ports)
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# helpers
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
@staticmethod
|
||||
def _normalize_ports(ports):
|
||||
"""Assign sequential port numbers/names like the Dynamips ESW did."""
|
||||
port_number = 0
|
||||
normalized = []
|
||||
for port in ports:
|
||||
port = dict(port)
|
||||
port["name"] = f"Ethernet{port_number}"
|
||||
port["port_number"] = port_number
|
||||
normalized.append(port)
|
||||
port_number += 1
|
||||
return normalized
|
||||
|
||||
def _ubridge_bridge_name(self, port_number):
|
||||
"""Name of the per-port uBridge relay bridge (not a kernel interface)."""
|
||||
return f"{self._id}-{port_number}"
|
||||
|
||||
def _tap_name(self, port_number):
|
||||
"""Kernel TAP name for a port: ``<bridge>-<port>`` (host-unique via the bridge)."""
|
||||
return f"{self._bridge_name}-{port_number}"
|
||||
|
||||
def _port_settings(self, port_number):
|
||||
for port in self._ports_mapping:
|
||||
if port["port_number"] == port_number:
|
||||
return port
|
||||
return None
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# properties / serialisation
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
@property
|
||||
def nios(self):
|
||||
return self._nios
|
||||
|
||||
@property
|
||||
def ports_mapping(self):
|
||||
return self._ports_mapping
|
||||
|
||||
@ports_mapping.setter
|
||||
def ports_mapping(self, ports):
|
||||
if ports != self._ports_mapping:
|
||||
if len(self._nios) > 0 and len(ports) != len(self._ports_mapping):
|
||||
raise NodeError("Cannot change the port count of a switch that is already connected.")
|
||||
self._ports_mapping = self._normalize_ports(ports)
|
||||
|
||||
@property
|
||||
def console(self):
|
||||
return self._console
|
||||
|
||||
@console.setter
|
||||
def console(self, console):
|
||||
self._console = console
|
||||
|
||||
@property
|
||||
def console_type(self):
|
||||
return self._console_type
|
||||
|
||||
@console_type.setter
|
||||
def console_type(self, console_type):
|
||||
self._console_type = console_type
|
||||
|
||||
def asdict(self):
|
||||
|
||||
@@ -44,61 +165,375 @@ class EthernetSwitch(BaseNode):
|
||||
"name": self.name,
|
||||
"usage": self.usage,
|
||||
"node_id": self.id,
|
||||
"project_id": self.project.id
|
||||
"project_id": self.project.id,
|
||||
"ports_mapping": self._ports_mapping,
|
||||
"console": self.console,
|
||||
"console_type": self.console_type,
|
||||
# The switch is always-on once created (a kernel bridge), like the ESW.
|
||||
"status": "started",
|
||||
}
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# lifecycle
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
async def create(self):
|
||||
"""
|
||||
Creates this switch.
|
||||
"""
|
||||
|
||||
super().create()
|
||||
await self.start()
|
||||
log.info(f'Ethernet switch "{self._name}" [{self._id}] has been created')
|
||||
|
||||
async def start(self):
|
||||
"""
|
||||
Starts this switch: bring up uBridge, create the kernel bridge, and
|
||||
re-wire any ports already bound before a restart.
|
||||
"""
|
||||
|
||||
if not self._started:
|
||||
if self._ubridge_hypervisor and self._ubridge_hypervisor.is_running():
|
||||
await self._stop_ubridge()
|
||||
await self._start_ubridge(self._ubridge_require_privileged_access)
|
||||
await self._ensure_bridge()
|
||||
for port_number in self._nios:
|
||||
if self._nios[port_number]:
|
||||
try:
|
||||
await self._add_ubridge_connection(self._nios[port_number], port_number)
|
||||
except (UbridgeError, NodeError) as e:
|
||||
self._started = False
|
||||
raise e
|
||||
self._started = True
|
||||
|
||||
async def _ensure_bridge(self):
|
||||
"""
|
||||
Creates the per-node kernel bridge once and enables VLAN filtering.
|
||||
Applies the bridge-level QinQ ethertype if any port needs it.
|
||||
|
||||
The bridge name is deterministic: ``gns3`` + the first 6 hex chars of
|
||||
this switch's UUID (kernel interface names are ≤ 15 chars). A stale
|
||||
bridge from a previous crash is deleted first so ``brctl create`` never
|
||||
hits EEXIST.
|
||||
"""
|
||||
|
||||
if self._bridge_created:
|
||||
return
|
||||
# deterministic short name — 10 chars, always fits the 15-char kernel cap
|
||||
self._bridge_name = "gns3" + self._id.replace("-", "")[:6]
|
||||
# crash recovery: best-effort delete any leftover bridge
|
||||
try:
|
||||
await self._ubridge_send(f'brctl delete "{self._bridge_name}"')
|
||||
except UbridgeError:
|
||||
pass # not found = nothing to clean
|
||||
await self._ubridge_send(f'brctl create "{self._bridge_name}"')
|
||||
# ``brctl create`` leaves the bridge DOWN; bring it UP so it forwards.
|
||||
await self._ubridge_send(f'link set "{self._bridge_name}" up')
|
||||
await self._ubridge_send(f'brctl vlanfiltering "{self._bridge_name}" on')
|
||||
self._bridge_created = True
|
||||
await self._apply_bridge_proto_if_needed()
|
||||
|
||||
async def _apply_bridge_proto_if_needed(self):
|
||||
"""
|
||||
If any port is a QinQ port using the 802.1ad ethertype (0x88a8), switch
|
||||
the whole bridge to that protocol. A Linux bridge has a single VLAN
|
||||
protocol, so mixed QinQ ethertypes within one switch are not supported.
|
||||
"""
|
||||
|
||||
proto = None
|
||||
for port in self._ports_mapping:
|
||||
if port.get("type") == "qinq":
|
||||
# normalise case: the schema carries uppercase (e.g. "0x88A8") but
|
||||
# brctl setvlanproto wants lowercase hex
|
||||
ethertype = port.get("ethertype", "0x8100").lower()
|
||||
if ethertype not in _SUPPORTED_VLAN_ETHERTYPE:
|
||||
raise NodeError(
|
||||
f"VLAN ethertype {ethertype} is not supported by the Linux bridge "
|
||||
f"(only 0x8100/0x88a8) for QinQ port {port['name']}"
|
||||
)
|
||||
if ethertype == _QINQ_ETHERTYPE:
|
||||
proto = _QINQ_ETHERTYPE
|
||||
if proto and not self._bridge_proto_set:
|
||||
await self._ubridge_send(f'brctl setvlanproto "{self._bridge_name}" {proto}')
|
||||
self._bridge_proto_set = True
|
||||
|
||||
async def delete(self):
|
||||
"""
|
||||
Deletes this switch.
|
||||
"""
|
||||
|
||||
raise NotImplementedError()
|
||||
return await self.close()
|
||||
|
||||
async def close(self):
|
||||
"""
|
||||
Closes this switch: release UDP ports, tear down the kernel bridge, stop uBridge.
|
||||
"""
|
||||
|
||||
if not (await super().close()):
|
||||
return False
|
||||
|
||||
for nio in self._nios.values():
|
||||
if nio and isinstance(nio, NIOUDP):
|
||||
self.manager.port_manager.release_udp_port(nio.lport, self._project)
|
||||
self._nios.clear()
|
||||
self._tap_by_port.clear()
|
||||
|
||||
if self._ubridge_hypervisor and self._ubridge_hypervisor.is_running() and self._bridge_created:
|
||||
try:
|
||||
# Deleting the bridge releases its enslaved TAPs; uBridge destroys
|
||||
# them when it stops below.
|
||||
await self._ubridge_send(f'brctl delete "{self._bridge_name}"')
|
||||
except UbridgeError as e:
|
||||
log.warning(f'Could not delete kernel bridge "{self._bridge_name}": {e}')
|
||||
self._bridge_created = False
|
||||
self._bridge_proto_set = False
|
||||
self._bridge_name = None
|
||||
self._started = False
|
||||
|
||||
await self._stop_ubridge()
|
||||
log.info(f'Ethernet switch "{self._name}" [{self._id}] has been closed')
|
||||
return True
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# per-port wiring
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
async def add_nio(self, nio, port_number):
|
||||
"""
|
||||
Adds a NIO as new port on this switch.
|
||||
Adds a NIO as a new port on this switch.
|
||||
|
||||
:param nio: NIO instance to add
|
||||
:param port_number: port to allocate for the NIO
|
||||
"""
|
||||
|
||||
raise NotImplementedError()
|
||||
if port_number in self._nios:
|
||||
raise NodeError(f"Port {port_number} isn't free")
|
||||
if not isinstance(nio, NIOUDP):
|
||||
raise NodeError("Ethernet switch ports only support UDP NIOs")
|
||||
|
||||
log.info(
|
||||
'Ethernet switch "{name}" [{id}]: NIO {nio} bound to port {port}'.format(
|
||||
name=self._name, id=self._id, nio=nio, port=port_number
|
||||
)
|
||||
)
|
||||
try:
|
||||
await self.start()
|
||||
await self._add_ubridge_connection(nio, port_number)
|
||||
self._nios[port_number] = nio
|
||||
except (NodeError, UbridgeError) as e:
|
||||
log.error('Cannot add NIO on Ethernet switch "{name}": {error}'.format(name=self._name, error=e))
|
||||
await self._stop_ubridge()
|
||||
self.status = "stopped"
|
||||
self._nios[port_number] = nio
|
||||
self.project.emit("log.error", {"message": str(e)})
|
||||
|
||||
async def _add_ubridge_connection(self, nio, port_number):
|
||||
"""
|
||||
Wires one port: a per-port uBridge relay (nio_tap <-> nio_udp) whose TAP
|
||||
is enslaved to the kernel bridge, with the port's VLAN mode applied.
|
||||
"""
|
||||
|
||||
port_settings = self._port_settings(port_number)
|
||||
if port_settings is None:
|
||||
raise NodeError(f"Port {port_number} doesn't exist on Ethernet switch '{self.name}'")
|
||||
|
||||
ubridge_bridge = self._ubridge_bridge_name(port_number)
|
||||
tap = self._tap_name(port_number)
|
||||
|
||||
# per-port uBridge relay -- uBridge holds the TAP fd
|
||||
await self._ubridge_send(f"bridge create {ubridge_bridge}")
|
||||
await self._ubridge_send(f'bridge add_nio_tap {ubridge_bridge} "{tap}"')
|
||||
# enslave the same TAP to the kernel bridge (the cloud.py::_add_linux_ethernet move)
|
||||
await self._ubridge_send(f'brctl addif "{self._bridge_name}" "{tap}"')
|
||||
# VLAN membership for this port's access/trunk/qinq mode
|
||||
await self._apply_port_vlan(port_settings, tap)
|
||||
# GNS3 link endpoint
|
||||
await self._ubridge_send(
|
||||
"bridge add_nio_udp {name} {lport} {rhost} {rport}".format(
|
||||
name=ubridge_bridge, lport=nio.lport, rhost=nio.rhost, rport=nio.rport
|
||||
)
|
||||
)
|
||||
await self._ubridge_apply_filters(ubridge_bridge, nio.filters)
|
||||
await self._ubridge_apply_markers(ubridge_bridge, nio)
|
||||
if nio.capturing:
|
||||
await self._ubridge_send(
|
||||
'bridge start_capture {name} "{output_file}"'.format(
|
||||
name=ubridge_bridge, output_file=nio.pcap_output_file
|
||||
)
|
||||
)
|
||||
await self._ubridge_send(f"bridge start {ubridge_bridge}")
|
||||
self._tap_by_port[port_number] = tap
|
||||
|
||||
async def _delete_ubridge_connection(self, port_number):
|
||||
"""
|
||||
Tears down one port's wiring: release the TAP from the bridge and delete
|
||||
the per-port uBridge relay.
|
||||
"""
|
||||
|
||||
tap = self._tap_by_port.pop(port_number, None)
|
||||
ubridge_bridge = self._ubridge_bridge_name(port_number)
|
||||
if tap is not None and self._bridge_created:
|
||||
try:
|
||||
await self._ubridge_send(f'brctl delif "{self._bridge_name}" "{tap}"')
|
||||
except UbridgeError as e:
|
||||
log.warning(f'Could not remove TAP "{tap}" from bridge "{self._bridge_name}": {e}')
|
||||
try:
|
||||
await self._ubridge_send(f"bridge delete {ubridge_bridge}")
|
||||
except UbridgeError as e:
|
||||
log.warning(f"Could not delete uBridge bridge {ubridge_bridge}: {e}")
|
||||
|
||||
async def remove_nio(self, port_number):
|
||||
"""
|
||||
Removes the specified NIO as member of this switch.
|
||||
Removes the specified NIO from this switch.
|
||||
|
||||
:param port_number: allocated port number
|
||||
|
||||
:returns: the NIO that was bound to the allocated port
|
||||
"""
|
||||
|
||||
raise NotImplementedError()
|
||||
if port_number not in self._nios:
|
||||
raise NodeError(f"Port {port_number} is not allocated")
|
||||
|
||||
await self.stop_capture(port_number)
|
||||
nio = self._nios[port_number]
|
||||
if isinstance(nio, NIOUDP):
|
||||
self.manager.port_manager.release_udp_port(nio.lport, self._project)
|
||||
|
||||
log.info(
|
||||
'Ethernet switch "{name}" [{id}]: NIO {nio} removed from port {port}'.format(
|
||||
name=self._name, id=self._id, nio=nio, port=port_number
|
||||
)
|
||||
)
|
||||
del self._nios[port_number]
|
||||
if self._ubridge_hypervisor and self._ubridge_hypervisor.is_running():
|
||||
await self._delete_ubridge_connection(port_number)
|
||||
return nio
|
||||
|
||||
def get_nio(self, port_number):
|
||||
"""
|
||||
Gets a port NIO binding.
|
||||
|
||||
:param port_number: port number
|
||||
:returns: NIO instance
|
||||
"""
|
||||
|
||||
if port_number not in self._nios:
|
||||
raise NodeError(f"Port {port_number} is not connected")
|
||||
return self._nios[port_number]
|
||||
|
||||
async def update_nio(self, port_number, nio):
|
||||
"""
|
||||
Re-applies uBridge filters/markers for a port (called when a link is updated).
|
||||
"""
|
||||
|
||||
ubridge_bridge = self._ubridge_bridge_name(port_number)
|
||||
if self._ubridge_hypervisor and self._ubridge_hypervisor.is_running():
|
||||
await self._ubridge_apply_filters(ubridge_bridge, nio.filters)
|
||||
await self._ubridge_apply_markers(ubridge_bridge, nio)
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# VLAN mode translation
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
async def _reset_port_vlan(self, tap):
|
||||
"""
|
||||
Resets a port's VLAN membership to the kernel default (PVID 1, untagged)
|
||||
by re-enslaving it. Used before re-applying a changed mode so stale VIDs
|
||||
from the previous mode do not leak.
|
||||
"""
|
||||
|
||||
await self._ubridge_send(f'brctl delif "{self._bridge_name}" "{tap}"')
|
||||
await self._ubridge_send(f'brctl addif "{self._bridge_name}" "{tap}"')
|
||||
|
||||
async def _apply_port_vlan(self, port_settings, tap):
|
||||
"""
|
||||
Translates an ESW port mode into ``brctl`` VLAN primitives. The port must
|
||||
already be enslaved to the bridge and carry the default PVID 1.
|
||||
|
||||
- access VLAN N: drop default 1, add N as PVID + egress untagged.
|
||||
- dot1q trunk (native N): drop default 1, admit all VIDs tagged, then mark
|
||||
the native VLAN PVID + untagged. (The ESW model declares only the native
|
||||
VLAN per trunk port, so the trunk admits all VIDs, like the emulated ESW.)
|
||||
- qinq (outer N): the bridge-level protocol is set separately; the port
|
||||
gets the service VLAN as PVID + untagged so customer frames are S-tagged.
|
||||
"""
|
||||
|
||||
br = self._bridge_name
|
||||
port_type = port_settings["type"]
|
||||
vlan = int(port_settings["vlan"])
|
||||
|
||||
if port_type == "access":
|
||||
await self._ubridge_send(f'brctl vlan_del "{br}" "{tap}" 1')
|
||||
await self._ubridge_send(f'brctl vlan_add "{br}" "{tap}" {vlan} pvid untagged')
|
||||
elif port_type == "dot1q":
|
||||
await self._ubridge_send(f'brctl vlan_del "{br}" "{tap}" 1')
|
||||
await self._ubridge_send(f'brctl vlan_add "{br}" "{tap}" 1 vid 4094')
|
||||
await self._ubridge_send(f'brctl vlan_add "{br}" "{tap}" {vlan} pvid untagged')
|
||||
elif port_type == "qinq":
|
||||
# setvlanproto is applied at the bridge level by _apply_bridge_proto_if_needed
|
||||
await self._ubridge_send(f'brctl vlan_del "{br}" "{tap}" 1')
|
||||
await self._ubridge_send(f'brctl vlan_add "{br}" "{tap}" {vlan} pvid untagged')
|
||||
else:
|
||||
raise NodeError(f"Unknown port type '{port_type}' on Ethernet switch '{self.name}'")
|
||||
|
||||
async def update_port_settings(self):
|
||||
"""
|
||||
Re-applies port settings (called after ``ports_mapping`` is updated). For
|
||||
ports already wired, reset then re-apply so a mode/VLAN change fully
|
||||
replaces the previous VLAN membership.
|
||||
"""
|
||||
|
||||
await self._apply_bridge_proto_if_needed()
|
||||
if not (self._ubridge_hypervisor and self._ubridge_hypervisor.is_running() and self._bridge_created):
|
||||
return
|
||||
for port_settings in self._ports_mapping:
|
||||
port_number = port_settings["port_number"]
|
||||
tap = self._tap_by_port.get(port_number)
|
||||
if tap is None:
|
||||
continue
|
||||
await self._reset_port_vlan(tap)
|
||||
await self._apply_port_vlan(port_settings, tap)
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# capture
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
async def start_capture(self, port_number, output_file, data_link_type="DLT_EN10MB"):
|
||||
"""
|
||||
Starts a packet capture.
|
||||
Starts a packet capture on a port (uBridge captures on the per-port relay).
|
||||
|
||||
:param port_number: allocated port number
|
||||
:param output_file: PCAP destination file for the capture
|
||||
:param data_link_type: PCAP data link type (DLT_*), default is DLT_EN10MB
|
||||
"""
|
||||
|
||||
raise NotImplementedError()
|
||||
nio = self.get_nio(port_number)
|
||||
if nio.capturing:
|
||||
raise NodeError(f"Packet capture is already activated on port {port_number}")
|
||||
nio.start_packet_capture(output_file)
|
||||
if self._ubridge_hypervisor and self._ubridge_hypervisor.is_running():
|
||||
ubridge_bridge = self._ubridge_bridge_name(port_number)
|
||||
await self._ubridge_send(f'bridge start_capture {ubridge_bridge} "{output_file}"')
|
||||
log.info(
|
||||
'Ethernet switch "{name}" [{id}]: starting packet capture on port {port}'.format(
|
||||
name=self.name, id=self.id, port=port_number
|
||||
)
|
||||
)
|
||||
|
||||
async def stop_capture(self, port_number):
|
||||
"""
|
||||
Stops a packet capture.
|
||||
Stops a packet capture on a port.
|
||||
|
||||
:param port_number: allocated port number
|
||||
"""
|
||||
|
||||
raise NotImplementedError()
|
||||
nio = self.get_nio(port_number)
|
||||
if not nio.capturing:
|
||||
return
|
||||
nio.stop_packet_capture()
|
||||
if self._ubridge_hypervisor and self._ubridge_hypervisor.is_running():
|
||||
ubridge_bridge = self._ubridge_bridge_name(port_number)
|
||||
await self._ubridge_send(f"bridge stop_capture {ubridge_bridge}")
|
||||
log.info(
|
||||
'Ethernet switch "{name}" [{id}]: stopping packet capture on port {port}'.format(
|
||||
name=self.name, id=self.id, port=port_number
|
||||
)
|
||||
)
|
||||
@@ -87,6 +87,23 @@ class Nat(Cloud):
|
||||
return True
|
||||
|
||||
def asdict(self):
|
||||
|
||||
nat_interface = self._ports_mapping[0].get("interface", "") if self._ports_mapping else ""
|
||||
|
||||
host_interfaces = []
|
||||
network_interfaces = gns3server.utils.interfaces.interfaces()
|
||||
for interface in network_interfaces:
|
||||
if interface["name"] == nat_interface:
|
||||
host_interfaces.append(
|
||||
{
|
||||
"name": interface["name"],
|
||||
"type": interface["type"],
|
||||
"special": interface["special"],
|
||||
"ip_addresses": interface.get("ip_addresses", []),
|
||||
}
|
||||
)
|
||||
break
|
||||
|
||||
return {
|
||||
"name": self.name,
|
||||
"usage": self.usage,
|
||||
@@ -94,4 +111,5 @@ class Nat(Cloud):
|
||||
"project_id": self.project.id,
|
||||
"status": "started",
|
||||
"ports_mapping": self.ports_mapping,
|
||||
"interfaces": host_interfaces,
|
||||
}
|
||||
@@ -1228,6 +1228,7 @@ class DockerVM(BaseNode):
|
||||
)
|
||||
await self._ubridge_send(f"bridge start {bridge_name}")
|
||||
await self._ubridge_apply_filters(bridge_name, nio.filters)
|
||||
await self._ubridge_apply_markers(bridge_name, nio)
|
||||
|
||||
async def adapter_add_nio_binding(self, adapter_number, nio):
|
||||
"""
|
||||
@@ -1268,7 +1269,7 @@ class DockerVM(BaseNode):
|
||||
bridge_name = f"bridge{adapter_number}"
|
||||
if bridge_name in self._bridges:
|
||||
await self._ubridge_apply_filters(bridge_name, nio.filters)
|
||||
|
||||
await self._ubridge_apply_markers(bridge_name, nio)
|
||||
async def adapter_remove_nio_binding(self, adapter_number):
|
||||
"""
|
||||
Removes an adapter NIO binding.
|
||||
|
||||
@@ -376,6 +376,7 @@ class Dynamips(BaseManager):
|
||||
raise DynamipsError(f"Could not create an UDP connection to {rhost}:{rport}: {e}")
|
||||
nio = NIOUDP(node, lport, rhost, rport)
|
||||
nio.filters = nio_settings.get("filters", {})
|
||||
nio.markers = nio_settings.get("markers", {})
|
||||
nio.suspend = nio_settings.get("suspend", False)
|
||||
elif nio_settings["type"] == "nio_generic_ethernet":
|
||||
ethernet_device = nio_settings["ethernet_device"]
|
||||
|
||||
@@ -40,6 +40,7 @@ class NIO:
|
||||
self._hypervisor = hypervisor
|
||||
self._name = name
|
||||
self._filters = {}
|
||||
self._markers = {}
|
||||
self._suspended = False
|
||||
self._capturing = False
|
||||
self._pcap_output_file = ""
|
||||
@@ -303,6 +304,26 @@ class NIO:
|
||||
assert isinstance(new_filters, dict)
|
||||
self._filters = new_filters
|
||||
|
||||
@property
|
||||
def markers(self):
|
||||
"""
|
||||
Returns the list of traffic-insight markers for this NIO.
|
||||
|
||||
:returns: markers (dictionary)
|
||||
"""
|
||||
|
||||
return self._markers
|
||||
|
||||
@markers.setter
|
||||
def markers(self, new_markers):
|
||||
"""
|
||||
Set markers for this NIO.
|
||||
|
||||
:param new_markers: markers (dictionary)
|
||||
"""
|
||||
|
||||
self._markers = new_markers
|
||||
|
||||
@property
|
||||
def capturing(self):
|
||||
"""
|
||||
|
||||
@@ -82,10 +82,12 @@ class NIOUDP(NIO):
|
||||
self._source_nio = nio_udp.NIOUDP(self._local_tunnel_rport, "127.0.0.1", self._local_tunnel_lport)
|
||||
self._destination_nio = nio_udp.NIOUDP(self._lport, self._rhost, self._rport)
|
||||
self._destination_nio.filters = self._filters
|
||||
self._destination_nio.markers = self._markers
|
||||
await self._node.add_ubridge_udp_connection(self._bridge_name, self._source_nio, self._destination_nio)
|
||||
|
||||
async def update(self):
|
||||
self._destination_nio.filters = self._filters
|
||||
self._destination_nio.markers = self._markers
|
||||
await self._node.update_ubridge_udp_connection(self._bridge_name, self._source_nio, self._destination_nio)
|
||||
|
||||
async def close(self):
|
||||
|
||||
@@ -746,6 +746,7 @@ class IOUVM(BaseNode):
|
||||
)
|
||||
|
||||
await self._ubridge_apply_filters(bay_id, unit_id, nio.filters)
|
||||
await self._ubridge_apply_markers(bay_id, unit_id, nio)
|
||||
unit_id += 1
|
||||
bay_id += 1
|
||||
|
||||
@@ -1067,6 +1068,7 @@ class IOUVM(BaseNode):
|
||||
)
|
||||
)
|
||||
await self._ubridge_apply_filters(adapter_number, port_number, nio.filters)
|
||||
await self._ubridge_apply_markers(adapter_number, port_number, nio)
|
||||
|
||||
async def adapter_update_nio_binding(self, adapter_number, port_number, nio):
|
||||
"""
|
||||
@@ -1079,6 +1081,7 @@ class IOUVM(BaseNode):
|
||||
|
||||
if self.ubridge:
|
||||
await self._ubridge_apply_filters(adapter_number, port_number, nio.filters)
|
||||
await self._ubridge_apply_markers(adapter_number, port_number, nio)
|
||||
|
||||
async def _ubridge_apply_filters(self, adapter_number, port_number, filters):
|
||||
"""
|
||||
@@ -1095,6 +1098,64 @@ class IOUVM(BaseNode):
|
||||
cmd = "iol_bridge add_packet_filter {} {}".format(location, filter)
|
||||
await self._ubridge_send(cmd)
|
||||
|
||||
async def _ubridge_apply_markers(self, adapter_number, port_number, nio):
|
||||
"""
|
||||
(Re-)apply traffic-insight markers to the IOL bridge.
|
||||
|
||||
IOU uses ``iol_bridge`` (not ``bridge``) and the ``add_packet_filter``
|
||||
command carries extra ``{bay} {unit}`` positional arguments between the
|
||||
bridge name and the filter name — this override mirrors the pattern in
|
||||
``_ubridge_apply_filters`` above.
|
||||
|
||||
:param adapter_number: bay id
|
||||
:param port_number: unit id
|
||||
:param nio: NIO instance carrying ``nio.markers``
|
||||
"""
|
||||
from gns3server.compute.marker.marker_manager import MarkerManager
|
||||
|
||||
markers = nio.markers if hasattr(nio, 'markers') else {}
|
||||
if not markers:
|
||||
return
|
||||
|
||||
manager = MarkerManager.instance()
|
||||
markers_dir = self.project.markers_working_directory()
|
||||
bridge_name = f"IOL-BRIDGE-{self.application_id + 512}"
|
||||
location = "{bridge_name} {bay} {unit}".format(
|
||||
bridge_name=bridge_name, bay=adapter_number, unit=port_number
|
||||
)
|
||||
for name, spec in markers.items():
|
||||
bpf = spec.get("bpf", "")
|
||||
tag = spec.get("tag")
|
||||
link_id = spec.get("link_id", "")
|
||||
pcap_path = os.path.join(
|
||||
markers_dir, f"{self._id}_{link_id}_{name}.pcap"
|
||||
)
|
||||
# Build the iol_bridge marker filter command:
|
||||
# iol_bridge add_packet_filter {br} {bay} {unit} {name} mark "{bpf}" [tag {id}] pcap "{path}"
|
||||
cmd = 'iol_bridge add_packet_filter {loc} {name} mark "{bpf}"'.format(
|
||||
loc=location, name=name, bpf=bpf
|
||||
)
|
||||
if tag is not None:
|
||||
cmd += f" tag {tag}"
|
||||
# IOU uses one per-node IOL-BRIDGE for every link, so bridge+filter
|
||||
# are identical across this node's links — `link` is the only way the
|
||||
# controller can tell their signals apart (contract §3.2).
|
||||
if link_id:
|
||||
cmd += f" link {link_id}"
|
||||
cmd += ' pcap "{path}"'.format(path=pcap_path)
|
||||
try:
|
||||
await self._ubridge_send(cmd)
|
||||
except UbridgeError as e:
|
||||
if "syntax error" in str(e).lower() or "compile filter" in str(e).lower():
|
||||
message = f"Warning: ignoring marker '{name}' due to BPF syntax error: {e}"
|
||||
log.warning(message)
|
||||
self.project.emit("log.warning", {"message": message})
|
||||
continue
|
||||
raise
|
||||
manager.register(
|
||||
str(self.project.id), self._id, name, link_id, tag
|
||||
)
|
||||
|
||||
async def adapter_remove_nio_binding(self, adapter_number, port_number):
|
||||
"""
|
||||
Removes an adapter NIO binding.
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
#!/usr/bin/env python
|
||||
#
|
||||
# Copyright (C) 2024 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/>.
|
||||
#
|
||||
#
|
||||
# Traffic-insight marker subsystem (compute side).
|
||||
#
|
||||
# ubridge's ``marker`` module is a passive tap: on a BPF match it emits a UDP
|
||||
# ``MARK`` signal to a configured sink and/or appends the packet to a pcap.
|
||||
# This package owns the compute-side UDP sink: one listener per compute process
|
||||
# serves every ubridge on that host, disambiguated by ``node=<id>``.
|
||||
@@ -0,0 +1,109 @@
|
||||
#!/usr/bin/env python
|
||||
#
|
||||
# Copyright (C) 2024 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
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class MarkerListener(asyncio.DatagramProtocol):
|
||||
"""
|
||||
Receives ubridge ``MARK`` signal datagrams and turns each into a
|
||||
``marker.match`` notification.
|
||||
|
||||
Signal format (one datagram per match, ASCII)::
|
||||
|
||||
MARK <sec.usec> node=<id> filter=<name> tag=<tag> len=<n>\\n
|
||||
|
||||
The signal carries metadata only (no packet bytes). The compute-side
|
||||
:class:`~gns3server.compute.marker.marker_manager.MarkerManager` registry
|
||||
resolves ``(node_id, filter_name)`` to ``(project_id, link_id, tag)`` so the
|
||||
event can be emitted on the right project-scoped notification stream.
|
||||
"""
|
||||
|
||||
def __init__(self, manager):
|
||||
# MarkerManager owns this listener and the registry.
|
||||
self._manager = manager
|
||||
self.transport = None
|
||||
|
||||
def connection_made(self, transport):
|
||||
self.transport = transport
|
||||
|
||||
def datagram_received(self, data, addr):
|
||||
try:
|
||||
self._handle(data)
|
||||
except Exception:
|
||||
# Never let a malformed datagram kill the listener.
|
||||
log.exception("Failed to process MARK datagram from %s: %r", addr, data)
|
||||
|
||||
def _handle(self, data):
|
||||
line = data.decode("utf-8", errors="replace").strip()
|
||||
if not line.startswith("MARK"):
|
||||
return
|
||||
|
||||
parts = line.split()
|
||||
# parts[0] == "MARK"; parts[1] == "<sec.usec>"
|
||||
if len(parts) < 2:
|
||||
return
|
||||
|
||||
try:
|
||||
ts = float(parts[1])
|
||||
except ValueError:
|
||||
log.warning("Ignoring MARK signal with bad timestamp: %r", line)
|
||||
return
|
||||
|
||||
kv = {}
|
||||
for token in parts[2:]:
|
||||
if "=" in token:
|
||||
key, value = token.split("=", 1)
|
||||
kv[key] = value
|
||||
|
||||
node_id = kv.get("node")
|
||||
filter_name = kv.get("filter")
|
||||
if not node_id or not filter_name:
|
||||
return
|
||||
|
||||
# "-" means the field was unset on the ubridge side (see contract §3.3).
|
||||
link = kv.get("link")
|
||||
tag = kv.get("tag")
|
||||
length = kv.get("len")
|
||||
|
||||
project_id, link_id, registered_tag = self._manager.lookup(node_id, filter_name)
|
||||
if project_id is None:
|
||||
log.warning(
|
||||
"MARK signal for unregistered node=%s filter=%s, dropping", node_id, filter_name
|
||||
)
|
||||
return
|
||||
|
||||
# `link=` is the authoritative per-link id (opaque, set by gns3server at
|
||||
# filter install time). It disambiguates signals that share a node+filter
|
||||
# across several links; fall back to the registry's link only for legacy
|
||||
# signals that carry no `link=`.
|
||||
signal_link = link if link and link != "-" else None
|
||||
|
||||
event = {
|
||||
"project_id": project_id,
|
||||
"node_id": node_id,
|
||||
"link_id": signal_link or link_id,
|
||||
"filter": filter_name,
|
||||
# Prefer the value carried in the signal; fall back to the one we registered.
|
||||
"tag": tag if tag and tag != "-" else registered_tag,
|
||||
"ts": ts,
|
||||
"len": int(length) if length and length.isdigit() else 0,
|
||||
}
|
||||
self._manager.emit_match(project_id, event)
|
||||
@@ -0,0 +1,185 @@
|
||||
#!/usr/bin/env python
|
||||
#
|
||||
# Copyright (C) 2024 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.compute.marker.marker_listener import MarkerListener
|
||||
from gns3server.compute.notification_manager import NotificationManager
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class MarkerManager:
|
||||
"""
|
||||
Singleton owning the compute-side UDP sink for ubridge ``MARK`` signals and
|
||||
the registry that maps each ``(node_id, filter_name)`` back to its
|
||||
``(project_id, link_id, tag)``.
|
||||
|
||||
The registry is populated when a marker is created on a link (the compute
|
||||
endpoint has project_id + node_id from its route path and link_id/name/tag
|
||||
from the request body) and cleared when the marker is deleted or the project
|
||||
closed. At signal time it is an O(1) lookup — no node-table scan, and the
|
||||
signal payload is untouched.
|
||||
|
||||
One listener per compute process serves every ubridge on that host; source
|
||||
ubridges are disambiguated by ``node=<id>`` (UUID, globally unique).
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
|
||||
self._listener = None
|
||||
self._transport = None
|
||||
self._host = None
|
||||
self._port = None
|
||||
# Flat lookup: (node_id, filter_name) -> {"project_id", "link_id", "tag"}
|
||||
self._entries = {}
|
||||
# Reverse index for O(1) per-project teardown: project_id -> set of keys
|
||||
self._by_project = {}
|
||||
|
||||
@property
|
||||
def host(self):
|
||||
"""The host the UDP sink is reachable on (for ``marker sink``)."""
|
||||
return self._host
|
||||
|
||||
@property
|
||||
def port(self):
|
||||
"""The UDP port the sink is bound on (for ``marker sink``)."""
|
||||
return self._port
|
||||
|
||||
@property
|
||||
def running(self):
|
||||
return self._transport is not None
|
||||
|
||||
async def start(self, host="127.0.0.1", port=0):
|
||||
"""
|
||||
Bind the UDP sink. ``port=0`` lets the OS choose a free port, which is
|
||||
then read back and exposed via :attr:`port` for ``marker sink`` commands.
|
||||
"""
|
||||
|
||||
if self.running:
|
||||
return
|
||||
loop = asyncio.get_running_loop()
|
||||
self._listener = MarkerListener(self)
|
||||
try:
|
||||
self._transport, _ = await loop.create_datagram_endpoint(
|
||||
lambda: self._listener, local_addr=(host, port)
|
||||
)
|
||||
except OSError:
|
||||
if port != 0:
|
||||
log.warning(
|
||||
"Marker listener: port %s unavailable, falling back to OS-assigned port", port
|
||||
)
|
||||
try:
|
||||
self._transport, _ = await loop.create_datagram_endpoint(
|
||||
lambda: self._listener, local_addr=(host, 0)
|
||||
)
|
||||
except OSError as e:
|
||||
log.error(
|
||||
"Marker listener startup failed: %s. Traffic insight signals are unavailable.", e
|
||||
)
|
||||
self._listener = None
|
||||
return
|
||||
else:
|
||||
log.error(
|
||||
"Marker listener startup failed on OS-assigned port. Traffic insight signals are unavailable."
|
||||
)
|
||||
self._listener = None
|
||||
return
|
||||
sock = self._transport.get_extra_info("socket")
|
||||
self._host = host
|
||||
self._port = sock.getsockname()[1] if sock else port
|
||||
log.info("Marker signal sink listening on %s:%s", self._host, self._port)
|
||||
|
||||
async def stop(self):
|
||||
"""Close the UDP sink and drop the whole registry."""
|
||||
|
||||
if self._transport:
|
||||
self._transport.close()
|
||||
self._transport = None
|
||||
self._listener = None
|
||||
self._entries.clear()
|
||||
self._by_project.clear()
|
||||
self._host = None
|
||||
self._port = None
|
||||
|
||||
def register(self, project_id, node_id, filter_name, link_id, tag=None):
|
||||
"""
|
||||
Record that ``filter_name`` on ``node_id`` belongs to ``project_id`` /
|
||||
``link_id``. Called from the compute marker-start endpoint.
|
||||
|
||||
Re-registering the same key updates the stored tag (e.g. on re-add).
|
||||
"""
|
||||
|
||||
key = (node_id, filter_name)
|
||||
self._entries[key] = {"project_id": project_id, "link_id": link_id, "tag": tag}
|
||||
self._by_project.setdefault(project_id, set()).add(key)
|
||||
|
||||
def unregister(self, node_id, filter_name):
|
||||
"""Forget a single marker. Returns True if something was removed."""
|
||||
|
||||
key = (node_id, filter_name)
|
||||
entry = self._entries.pop(key, None)
|
||||
if entry is None:
|
||||
return False
|
||||
project_entries = self._by_project.get(entry["project_id"])
|
||||
if project_entries is not None:
|
||||
project_entries.discard(key)
|
||||
if not project_entries:
|
||||
self._by_project.pop(entry["project_id"], None)
|
||||
return True
|
||||
|
||||
def unregister_project(self, project_id):
|
||||
"""Drop every marker belonging to ``project_id`` (project close)."""
|
||||
|
||||
keys = self._by_project.pop(project_id, None)
|
||||
if not keys:
|
||||
return
|
||||
for key in keys:
|
||||
self._entries.pop(key, None)
|
||||
|
||||
def lookup(self, node_id, filter_name):
|
||||
"""
|
||||
O(1) resolution of an incoming signal to its project/link/tag.
|
||||
|
||||
:returns: (project_id, link_id, tag) or (None, None, None) on miss.
|
||||
"""
|
||||
|
||||
entry = self._entries.get((node_id, filter_name))
|
||||
if entry is None:
|
||||
return None, None, None
|
||||
return entry["project_id"], entry["link_id"], entry["tag"]
|
||||
|
||||
def emit_match(self, project_id, event):
|
||||
"""
|
||||
Forward a parsed match as a project-scoped ``marker.match`` notification.
|
||||
Flows compute -> controller dispatch -> project_emit -> web UI WS.
|
||||
"""
|
||||
|
||||
NotificationManager.instance().emit("marker.match", event, project_id=project_id)
|
||||
|
||||
_instance = None
|
||||
|
||||
@staticmethod
|
||||
def instance():
|
||||
if MarkerManager._instance is None:
|
||||
MarkerManager._instance = MarkerManager()
|
||||
return MarkerManager._instance
|
||||
|
||||
@staticmethod
|
||||
def reset():
|
||||
MarkerManager._instance = None
|
||||
@@ -30,6 +30,7 @@ class NIO:
|
||||
self._capturing = False
|
||||
self._suspended = False
|
||||
self._filters = {}
|
||||
self._markers = {}
|
||||
self._pcap_output_file = ""
|
||||
self._pcap_data_link_type = ""
|
||||
|
||||
@@ -118,3 +119,24 @@ class NIO:
|
||||
|
||||
assert isinstance(new_filters, dict)
|
||||
self._filters = new_filters
|
||||
|
||||
@property
|
||||
def markers(self):
|
||||
"""
|
||||
Returns the traffic-insight markers for this NIO.
|
||||
|
||||
:returns: markers (dictionary: name -> {bpf, tag, link_id})
|
||||
"""
|
||||
|
||||
return self._markers
|
||||
|
||||
@markers.setter
|
||||
def markers(self, new_markers):
|
||||
"""
|
||||
Set the traffic-insight markers for this NIO.
|
||||
|
||||
:param new_markers: markers (dictionary: name -> {bpf, tag, link_id})
|
||||
"""
|
||||
|
||||
assert isinstance(new_markers, dict)
|
||||
self._markers = new_markers
|
||||
@@ -80,5 +80,6 @@ class NIOUDP(NIO):
|
||||
"rport": self._rport,
|
||||
"rhost": self._rhost,
|
||||
"suspend": self._suspended,
|
||||
"filters": self._filters
|
||||
"filters": self._filters,
|
||||
"markers": self._markers
|
||||
}
|
||||
@@ -246,6 +246,22 @@ class Project:
|
||||
raise ComputeError(f"Could not create the capture working directory: {e}")
|
||||
return workdir
|
||||
|
||||
def markers_working_directory(self):
|
||||
"""
|
||||
Returns the working directory where uBridge writes per-link marker pcaps
|
||||
(matched packets, kept for later replay).
|
||||
|
||||
:returns: path to the directory
|
||||
"""
|
||||
|
||||
workdir = os.path.join(self._path, "project-files", "markers")
|
||||
if not self._deleted:
|
||||
try:
|
||||
os.makedirs(workdir, exist_ok=True)
|
||||
except OSError as e:
|
||||
raise ComputeError(f"Could not create the markers working directory: {e}")
|
||||
return workdir
|
||||
|
||||
def add_node(self, node):
|
||||
"""
|
||||
Adds a node to the project.
|
||||
|
||||
Reference in new issue
Block a user