Merge branch '3.1' into 3.1

This commit is contained in:
Jeremy Grossmann authored and GitHub committed 2026-10-19 18:11:29 +02:00
commit 68b5e89bfa
51 files changed
+3155 -183

No files matched your search

+8 -4
View File
@@ -297,11 +297,12 @@ class BaseManager:
return node
@staticmethod
def has_privileged_access(executable):
def has_privileged_access(executable, *, require_net_admin=False):
"""
Check if an executable have the right to attach to Ethernet and TAP adapters.
:param executable: executable path
:param require_net_admin: also require CAP_NET_ADMIN for network configuration
:returns: True or False
"""
@@ -324,8 +325,8 @@ class BaseManager:
try:
if sys.platform.startswith("linux") and "security.capability" in os.listxattr(executable):
caps = os.getxattr(executable, "security.capability")
# test the 2nd byte and check if the 13th bit (CAP_NET_RAW) is set
if struct.unpack("<IIIII", caps)[1] & 1 << 13:
required = (1 << 13) | ((1 << 12) if require_net_admin else 0)
if struct.unpack("<IIIII", caps)[1] & required == required:
return True
except (AttributeError, OSError) as e:
log.error(f"Could not determine if CAP_NET_RAW capability is set for {executable}: {e}")
@@ -375,7 +376,10 @@ class BaseManager:
nio.markers = nio_settings.get("markers", {})
nio.suspend = nio_settings.get("suspend", False)
elif nio_settings["type"] == "nio_bridge":
nio = NIOBridge(nio_settings["bridge"])
# A vxlan sub-spec marks a cross-compute link: the node creates
# the VXLAN device and enslaves it into the same per-link bridge
# next to its anchor (see KernelDatapathMixin._kernel_attach).
nio = NIOBridge(nio_settings["bridge"], nio_settings.get("vxlan"))
nio.filters = nio_settings.get("filters", {})
# Markers ride the NIO and attach to the veth host interface via
# uBridge's AF_PACKET marker module (marker add_kernel).
+43 -5
View File
@@ -15,12 +15,15 @@
# along with this program. If not, see <http://www.gnu.org/licenses/>.
import logging
import os
import re
import subprocess
import sys
import gns3server.utils.asyncio
import gns3server.utils.interfaces
from gns3server.compute.ubridge.ubridge_error import UbridgeError
from gns3server.schemas.compute.cloud_nodes import LINUX_TAP_NAME_PATTERN
from gns3server.utils.kernel_anchor import is_kernel_datapath_interface
from ...base_node import BaseNode
@@ -69,7 +72,7 @@ class Cloud(BaseNode):
for port in ports:
port["port_number"] = port_number
port_number += 1
self._ports_mapping = ports
self._ports_mapping = self._normalize_ports_mapping(ports)
@property
def nios(self):
@@ -101,6 +104,9 @@ class Cloud(BaseNode):
}
)
if sys.platform.startswith("linux") and gns3server.utils.interfaces.is_interface_bridge(interface["name"]):
host_interfaces[-1]["is_bridge"] = True
return {
"name": self.name,
"usage": self.usage,
@@ -196,6 +202,11 @@ class Cloud(BaseNode):
self._remote_console_http_path = remote_console_http_path
@staticmethod
def _normalize_ports_mapping(ports):
# API responses include bridge=None; legacy projects omit it.
return [{key: value for key, value in port.items() if key != "bridge" or value is not None} for port in ports]
@property
def ports_mapping(self):
"""
@@ -214,6 +225,7 @@ class Cloud(BaseNode):
:param ports: ports info
"""
ports = self._normalize_ports_mapping(ports)
if ports != self._ports_mapping:
if len(self._nios) > 0:
raise NodeError("Cannot modify a cloud that is already connected.")
@@ -314,7 +326,9 @@ 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):
if not self.manager.has_privileged_access(
self.ubridge_path, require_net_admin=bool(port_info.get("bridge"))
):
raise NodeError(
"uBridge requires root access or the capability to interact with Ethernet and TAP adapters"
)
@@ -334,9 +348,7 @@ class Cloud(BaseNode):
await self._add_windows_ethernet(port_info, bridge_name)
elif port_info["type"] == "tap":
await self._ubridge_send(
'bridge add_nio_tap {name} "{interface}"'.format(name=bridge_name, interface=port_info["interface"])
)
await self._add_tap_interface(port_info, bridge_name)
elif port_info["type"] == "udp":
await self._ubridge_send(
@@ -350,6 +362,32 @@ class Cloud(BaseNode):
await self._ubridge_send(f"bridge start {bridge_name}")
async def _add_tap_interface(self, port_info, bridge_name):
"""Open a TAP and restore its optional host bridge assignment."""
target_bridge = port_info.get("bridge")
if target_bridge:
self._validate_tap_bridge(port_info["interface"], target_bridge)
await self._ubridge_send(
'bridge add_nio_tap {name} "{interface}"'.format(name=bridge_name, interface=port_info["interface"])
)
if target_bridge:
await self._ubridge_send(f'brctl addif "{target_bridge}" "{port_info["interface"]}"')
def _validate_tap_bridge(self, tap, target_bridge):
"""Validate host resources before creating or attaching a Cloud TAP."""
if not sys.platform.startswith("linux"):
raise NodeError("TAP bridge assignments are only supported on Linux")
if not all(re.fullmatch(LINUX_TAP_NAME_PATTERN, name) for name in (target_bridge, tap)):
raise NodeError("Invalid TAP or bridge interface name")
if target_bridge not in {
interface["name"] for interface in self._interfaces()
} or not gns3server.utils.interfaces.is_interface_bridge(target_bridge):
raise NodeError(f"Bridge '{target_bridge}' is missing or not allowed on this server")
master_path = os.path.join("/sys/class/net", tap, "master")
if os.path.exists(master_path) and os.path.basename(os.path.realpath(master_path)) != target_bridge:
raise NodeError(f"TAP '{tap}' already belongs to another bridge")
async def _add_linux_ethernet(self, port_info, bridge_name):
"""
Connects an Ethernet interface on Linux using raw sockets.
+3 -1
View File
@@ -369,7 +369,9 @@ class Dynamips(BaseManager):
# Kernel-datapath NIO: the router binds the port's anchor TAP to
# a hypervisor nio_tap and enslaves it into this per-link bridge
# (Router._attach_kernel_nio). No hypervisor NIO is created here.
nio = NIOBridge(nio_settings["bridge"])
# A vxlan sub-spec (cross-compute link) rides along untouched —
# the router's attach path wires it like any kernel link.
nio = NIOBridge(nio_settings["bridge"], nio_settings.get("vxlan"))
nio.filters = nio_settings.get("filters", {})
nio.markers = nio_settings.get("markers", {})
nio.suspend = nio_settings.get("suspend", False)
+115
View File
@@ -48,6 +48,9 @@ import asyncio
import contextlib
import logging
import os
import socket
import psutil
from gns3server.compute.error import NodeError
from gns3server.compute.ubridge.ubridge_error import UbridgeError
@@ -264,6 +267,86 @@ class KernelDatapathMixin:
return not (isinstance(nio, NIOBridge) and nio.bridge is None)
async def _ensure_vxlan_device(self, spec):
"""
Create (or replace) the cross-compute VXLAN device this link end
tunnels through and return the device name. The device name is a
pure function of the link id, so a device found under it belongs to
this link: either a leftover from a run that crashed without
cleanup, or the previous instance of a node restart — deleted and
re-created either way, which makes wiring idempotent. The lower
device is resolved locally from the peer address; the kernel
derives the tunnel MTU from it, which is what keeps the per-link
bridge honest about what the underlay can carry.
A create failure with the name just cleared can only mean the VNI —
the kernel refuses a VNI already registered on the shared dstport
socket (EEXIST) — so it is surfaced as a wiring error and the
controller retries the link with a fresh VNI.
"""
name = spec["name"]
remote = spec["remote"]
try:
await self._ubridge_send(f'vxlan show "{name}"')
except UbridgeError:
# Not present (or uBridge cannot answer): the create below is
# the real gate either way.
pass
else:
await self._ubridge_send(f'vxlan delete "{name}"')
dev = await self._vxlan_lowerdev(remote)
try:
await self._ubridge_send(
f'vxlan create "{name}" {spec["vni"]} remote={remote} dstport={spec["dstport"]} dev={dev}'
)
except UbridgeError as e:
raise self._kernel_error(f"Could not create VXLAN device {name} to {remote} via {dev}: {e}")
return name
async def _vxlan_lowerdev(self, remote):
"""
The local interface the route to *remote* egresses through — the
``dev=`` a cross-compute VXLAN device needs so the kernel can derive
its MTU from the underlay (peer MTU - 50) and pick the outer source
address. A connected UDP socket makes the kernel pick the route
without sending anything; the chosen source address is matched back
to its interface.
"""
try:
info = socket.getaddrinfo(remote, 9, socket.AF_UNSPEC, socket.SOCK_DGRAM)
except socket.gaierror as e:
raise self._kernel_error(f"Cannot resolve VXLAN peer {remote}: {e}")
last_error = None
for af, socktype, proto, _, sa in info:
try:
with socket.socket(af, socktype, proto) as sock:
sock.connect(sa)
local_ip = sock.getsockname()[0]
except OSError as e:
last_error = e
continue
dev = self._interface_with_address(local_ip)
if dev is not None:
return dev
raise self._kernel_error(f"No local interface owns {local_ip}, the route to VXLAN peer {remote}")
raise self._kernel_error(f"No route to VXLAN peer {remote}: {last_error}")
@staticmethod
def _interface_with_address(address):
"""
The name of the interface owning *address* (scope suffix stripped
for link-local IPv6), or None.
"""
plain = address.split("%")[0]
for name, addrs in psutil.net_if_addrs().items():
for addr in addrs:
if addr.address.split("%")[0] == plain:
return name
return None
async def _kernel_attach(self, anchor, nio):
"""
Attach an anchor to the per-link kernel bridge carried by *nio*
@@ -276,6 +359,12 @@ class KernelDatapathMixin:
bridge first: an existing bridge is success instead of a create race
failure.
A NIO carrying a ``vxlan`` spec is a cross-compute link: this end's
VXLAN device is (re-)created first and enslaved into the same
bridge next to the anchor, so the kernel encapsulates/decapsulates
the frames while filters, markers, capture and carrier keep running
on the anchor exactly like a same-host kernel link.
A NIO whose ``bridge`` is None means the anchor is bridged
elsewhere — an Ethernet switch absorbed it into the switch's own
bridge — so the membership work is skipped here and only the link
@@ -287,6 +376,13 @@ class KernelDatapathMixin:
"""
if nio.bridge is not None:
vxlan_name = None
if nio.vxlan:
# Replace/refresh the device before the bridge work: a
# leftover device may still be enslaved to a leftover bridge
# (its deletion un-enrolls it), and the fresh device must be
# in place when the bridge's MTU auto-adjust runs.
vxlan_name = await self._ensure_vxlan_device(nio.vxlan)
try:
await self._ubridge_send(f'brctl create "{nio.bridge}"')
except UbridgeError:
@@ -294,6 +390,8 @@ class KernelDatapathMixin:
await self._ubridge_send(f'brctl show "{nio.bridge}"')
await self._ubridge_send(f'link set "{nio.bridge}" up')
await self._ubridge_send(f'brctl addif "{nio.bridge}" "{anchor}"')
if vxlan_name is not None:
await self._ubridge_send(f'brctl addif "{nio.bridge}" "{vxlan_name}"')
if nio.capturing:
# Restore a capture that was active before a node restart
# (mirrors the relay path's start_capture).
@@ -373,6 +471,11 @@ class KernelDatapathMixin:
enslaved fails with EBUSY, so the last endpoint to remove its port
wins the deletion and the loser's failure is expected and suppressed.
A NIO carrying a ``vxlan`` spec is a cross-compute link: this end
also deletes its own tunnel device (the peer deletes its own on its
compute); the kernel's unregister path releases the bridge port, so
it goes before the bridge delete, same as the anchor.
A NIO whose ``bridge`` is None owns no bridge here: the anchor is
bridged by an Ethernet switch, which tears its own membership down
on its side; this end only releases the link state it applied
@@ -394,6 +497,9 @@ class KernelDatapathMixin:
with contextlib.suppress(UbridgeError):
await self._ubridge_send(f'brctl delif "{nio.bridge}" "{host_ifc}"')
if nio.bridge is not None:
if nio.vxlan:
with contextlib.suppress(UbridgeError):
await self._ubridge_send(f'vxlan delete "{nio.vxlan["name"]}"')
with contextlib.suppress(UbridgeError):
await self._ubridge_send(f'brctl delete "{nio.bridge}"')
@@ -407,6 +513,12 @@ class KernelDatapathMixin:
the same contract as link deletion. A node restart re-creates the
bridge from the NIO in the attach path, so nothing is lost by
deleting it.
A cross-compute NIO's tunnel device is swept with its bridge: each
end owns its own device, and a device left behind would hold its
VNI on the host's shared socket until a reopen's attach path
replaces it by name (which it does — the sweep just frees it
sooner).
"""
if not self.ubridge:
@@ -416,6 +528,9 @@ class KernelDatapathMixin:
# bridge None = externally bridged (an Ethernet switch owns
# the bridge); never delete a bridge this node does not own.
if isinstance(nio, NIOBridge) and nio.bridge is not None:
if nio.vxlan:
with contextlib.suppress(UbridgeError):
await self._ubridge_send(f'vxlan delete "{nio.vxlan["name"]}"')
with contextlib.suppress(UbridgeError):
await self._ubridge_send(f'brctl delete "{nio.bridge}"')
+24 -1
View File
@@ -31,6 +31,13 @@ veth host end (marker add_kernel / capture start_kernel).
Ethernet switch absorbed it into the switch's own kernel bridge — and this
node applies only the link state the anchor carries (carrier, markers,
impairments), never bridge membership.
A NIO may additionally carry ``vxlan`` (a BridgeVxlan spec): the link
crosses computes through a VXLAN device this end creates (uBridge's
``vxlan`` module) and enslaves into the same per-link bridge next to the
anchor. The kernel encapsulates/decapsulates the frames; everything else —
filters, markers, capture, carrier, suspend — still runs on the anchor, so
the datapath behaves exactly like a same-host kernel link.
"""
from .nio import NIO
@@ -42,12 +49,16 @@ class NIOBridge(NIO):
:param bridge: name of the per-link Linux kernel bridge, or None when
the anchor is bridged by an Ethernet switch (see module docstring)
:param vxlan: cross-compute tunnel spec (dict: name/vni/remote/dstport)
this end creates and enslaves into the bridge, or None for a
same-host kernel link
"""
def __init__(self, bridge):
def __init__(self, bridge, vxlan=None):
super().__init__()
self._bridge = bridge
self._vxlan = vxlan
@property
def bridge(self):
@@ -60,6 +71,17 @@ class NIOBridge(NIO):
return self._bridge
@property
def vxlan(self):
"""
Returns the cross-compute VXLAN tunnel spec, or None when the link
is wired on this host alone.
:returns: dict (name/vni/remote/dstport) or None
"""
return self._vxlan
def __str__(self):
return "NIO bridge"
@@ -69,6 +91,7 @@ class NIOBridge(NIO):
return {
"type": "nio_bridge",
"bridge": self._bridge,
"vxlan": self._vxlan,
"suspend": self._suspended,
"filters": self._filters,
"markers": self._markers,
+128 -30
View File
@@ -158,6 +158,9 @@ class QemuVM(KernelDatapathMixin, BaseNode):
self._hdc_disk_interface = "none"
self._hdd_disk_interface = "none"
self._cdrom_image = ""
self._cdrom2_image = ""
self._cdrom_interface = "ide"
self._cdrom2_interface = "ide"
self._bios_image = ""
self._boot_priority = "c"
self._mac_address = ""
@@ -524,33 +527,101 @@ class QemuVM(KernelDatapathMixin, BaseNode):
else:
self._cdrom_image = ""
@property
def cdrom2_image(self):
return self._cdrom2_image
@cdrom2_image.setter
def cdrom2_image(self, image):
self._cdrom2_image = self.manager.get_abs_image_path(image, self.working_dir) if image else ""
@property
def cdrom_interface(self):
return self._cdrom_interface
@cdrom_interface.setter
def cdrom_interface(self, interface):
self.validate_cdrom_interface("cdrom_interface", interface)
self._cdrom_interface = interface
@property
def cdrom2_interface(self):
return self._cdrom2_interface
@cdrom2_interface.setter
def cdrom2_interface(self, interface):
self.validate_cdrom_interface("cdrom2_interface", interface)
self._cdrom2_interface = interface
def validate_cdrom_interface(self, name, interface):
"""Validate without mutating the VM, including during API preflight."""
if interface not in ("ide", "sata"):
raise QemuError("CD/DVD interface must be ide or sata")
if interface != getattr(self, name) and self.is_running():
raise QemuError("Stop the VM before changing a CD/DVD interface")
async def update_property(self, name, value):
"""
Update Qemu VM properties.
"""
previous = getattr(self, name)
setattr(self, name, value)
if name == "cdrom_image":
# let the guest know about the new cdrom image
await self._update_cdrom_image()
try:
if name in {"cdrom_image", "cdrom2_image"}:
await self._update_cdrom_image(name)
except (Exception, asyncio.CancelledError):
# Old media may have been deleted while QEMU still has it open.
# Restore the stored path without resolving it again.
setattr(self, f"_{name}", previous)
raise
self._command_line_changed = True
async def _update_cdrom_image(self):
"""
Update the cdrom image path for the Qemu guest OS
"""
async def _update_cdrom_image(self, name="cdrom_image"):
"""Change media only on an existing drive, validating the affected slot."""
if not self.is_running():
return
image = getattr(self, name)
self._cdrom_option(name)
slot = 0 if name == "cdrom_image" else 1
interface = self.cdrom_interface if slot == 0 else self.cdrom2_interface
device = f"sata-cdrom{slot}" if interface == "sata" else f"ide1-cd{slot}"
reader, writer = await self._open_qemu_monitor_connection_vm()
if reader is None or writer is None:
raise QemuError("Could not connect to the QEMU monitor to change CD/DVD media")
if self.is_running():
if self._cdrom_image:
self._cdrom_option() # this will check the cdrom image is accessible
await self._control_vm("eject -f ide1-cd0")
await self._control_vm(f"change ide1-cd0 {self._cdrom_image}")
log.debug(
f'QEMU VM "{self._name}" [{self._id}] has changed the cdrom image path to {self._cdrom_image}'
)
async def command(text):
writer.write((text + "\n").encode("utf-8"))
await writer.drain()
# Consume the echo first: a filename may itself contain "(qemu) ".
await asyncio.wait_for(reader.readline(), timeout=3)
output = await asyncio.wait_for(reader.readuntil(b"(qemu) "), timeout=3)
response = output.decode("utf-8", errors="replace")
response = response.removesuffix("(qemu) ").strip()
if "Error:" in response or (text != "info block" and response):
raise QemuError(response)
return response
try:
await asyncio.wait_for(reader.readuntil(b"(qemu) "), timeout=3)
blocks = await command("info block")
if not re.search(rf"(?m)^{re.escape(device)}(?:\s+\([^\r\n]*\))?:", blocks):
raise QemuError("Stop the VM before adding this CD/DVD drive")
if image:
filename = image
if not image.isascii() or any(ord(char) < 32 for char in image):
# HMP's line editor cannot reliably handle non-ASCII bytes.
# A JSON block filename carries them as ASCII escapes, which
# the block layer decodes after the HMP parser has run.
filename = "json:" + json.dumps({"driver": "raw", "file": {"driver": "file", "filename": image}})
await command(f"change {device} -f {json.dumps(filename)}")
else:
await self._control_vm("eject -f ide1-cd0")
log.debug(f'QEMU VM "{self._name}" [{self._id}] has ejected the cdrom image')
await command(f"eject -f {device}")
log.debug('QEMU VM "%s" has changed %s to %s', self._name, name, image)
except (asyncio.TimeoutError, asyncio.IncompleteReadError, OSError) as e:
raise QemuError(f"Could not change CD/DVD media: {e}") from e
finally:
writer.close()
@property
def bios_image(self):
@@ -2387,20 +2458,45 @@ class QemuVM(KernelDatapathMixin, BaseNode):
return options
def _cdrom_option(self):
def _cdrom_option(self, name=None):
options = []
if self._cdrom_image:
if not os.path.isfile(self._cdrom_image) or not os.path.exists(self._cdrom_image):
if os.path.islink(self._cdrom_image):
raise QemuError(
f"cdrom image '{self._cdrom_image}' linked to '{os.path.realpath(self._cdrom_image)}' is not accessible"
)
else:
raise QemuError(f"cdrom image '{self._cdrom_image}' is not accessible")
if self._hdc_disk_image:
raise QemuError("You cannot use a disk image on hdc disk and a CDROM image at the same time")
options.extend(["-cdrom", self._cdrom_image.replace(",", ",,")])
for slot, image_name in enumerate(("cdrom_image", "cdrom2_image")):
image = getattr(self, image_name)
if name not in (None, image_name) or not image:
continue
if not os.path.isfile(image):
if os.path.islink(image):
raise QemuError(f"cdrom image '{image}' linked to '{os.path.realpath(image)}' is not accessible")
raise QemuError(f"cdrom image '{image}' is not accessible")
interface = self.cdrom_interface if slot == 0 else self.cdrom2_interface
if interface == "sata":
# Separate IDs avoid collisions with SATA hard disk controllers.
options.extend(
[
"-device",
f"ich9-ahci,id=cdrom-ahci{slot}",
"-drive",
f"file={image.replace(',', ',,')},if=none,id=sata-cdrom{slot},media=cdrom,readonly=on",
"-device",
f"ide-cd,drive=sata-cdrom{slot},bus=cdrom-ahci{slot}.0",
]
)
elif slot == 0:
if self._hdc_disk_image and self._hdc_disk_interface == "ide":
raise QemuError("You cannot use an IDE hdc disk and a CDROM image at the same time")
options.extend(["-cdrom", image.replace(",", ",,")])
else:
disk_interface = self._hdd_disk_interface
if self._create_config_disk and disk_interface == "none":
disk_interface = self._hda_disk_interface
if (self._hdd_disk_image or self._create_config_disk) and disk_interface == "ide":
raise QemuError("You cannot use an IDE hdd disk and a second CDROM image at the same time")
options.extend(
[
"-drive",
f"file={image.replace(',', ',,')},if=ide,index=3,media=cdrom,readonly=on",
]
)
return options
def _bios_option(self):
@@ -2868,6 +2964,8 @@ class QemuVM(KernelDatapathMixin, BaseNode):
answer["cdrom_image"] = self.manager.get_relative_image_path(self._cdrom_image, self.working_dir)
answer["cdrom_image_md5sum"] = md5sum(self._cdrom_image, self.working_dir)
answer["cdrom2_image"] = self.manager.get_relative_image_path(self._cdrom2_image, self.working_dir)
answer["cdrom2_image_md5sum"] = md5sum(self._cdrom2_image, self.working_dir)
answer["bios_image"] = self.manager.get_relative_image_path(self._bios_image, self.working_dir)
answer["bios_image_md5sum"] = md5sum(self._bios_image, self.working_dir)
answer["initrd"] = self.manager.get_relative_image_path(self._initrd, self.working_dir)
+93
View File
@@ -43,6 +43,7 @@ from typing import Optional
from gns3server.config import Config
from gns3server.utils.tc_capabilities import parse_tc_capabilities
from gns3server.utils.vxlan import VXLAN_VNI_MAX, VXLAN_VNI_MIN
from .hypervisor import Hypervisor
from .ubridge_error import UbridgeError
@@ -59,6 +60,8 @@ _tap_cache: dict[tuple[str, int, int, int], Optional[bool]] = {}
_iol_tap_cache: dict[tuple[str, int, int, int], Optional[bool]] = {}
# binary identity -> whether the bridge module can release a named TAP NIO (None = unknown)
_bridge_tap_cache: dict[tuple[str, int, int, int], Optional[bool]] = {}
# binary identity -> whether the vxlan module can create a device (None = unknown)
_vxlan_cache: dict[tuple[str, int, int, int], Optional[bool]] = {}
_lock = asyncio.Lock()
@@ -358,3 +361,93 @@ async def _probe_bridge_tap(path, config, timeout): # noqa: ASYNC109 - operatio
with contextlib.suppress(Exception):
await hypervisor.stop()
shutil.rmtree(working_dir, ignore_errors=True)
async def probe_vxlan_support(timeout: float = 15.0):
"""
Whether this host's uBridge can create a VXLAN device (the ``vxlan``
module) — the carrier a cross-compute kernel link tunnels through.
Probed on a scratch device with a scratch VNI on a scratch dstport
(never the deployment's configured port, so the probe cannot disturb
live tunnels; a device on the port would still be harmless — the kernel
shares one socket — but the probe has no business joining it) and
deleted again, cached by binary identity like the other probes. Any
failure — old build, missing module, no CAP_NET_ADMIN, an unloadable
kernel module — means unknown, reported as None and cached as such;
the controller reads unknown as "keep this link on the relay".
"""
config = Config.instance()
path = shutil.which(config.settings.Server.ubridge_path)
if not path:
return None
try:
stat = os.stat(path)
key = (path, stat.st_mtime_ns, stat.st_size, stat.st_ctime_ns)
except OSError:
return None
async with _lock:
if key in _vxlan_cache:
return _vxlan_cache[key]
supported = await _probe_vxlan(path, config, timeout)
_vxlan_cache[key] = supported
return supported
async def _probe_vxlan(path, config, timeout):
"""
Spawn a throwaway uBridge and run one ``vxlan create``/``delete`` cycle.
The scratch name (uuid suffix) cannot collide with a real link's
deterministic name; the scratch VNI and dstport are drawn from uuids so
two concurrent probes on one host (two servers, one uBridge user) cannot
fight over either. No ``dev=``: this probe answers "can this binary and
kernel create a VXLAN device at all" — resolving the real underlay
interface belongs to the link attach path, where an unroutable peer
means this particular link falls back to the relay, not that the
capability is absent.
"""
working_dir = tempfile.mkdtemp(prefix="gns3-vxlan-probe-")
hypervisor = Hypervisor(
None,
path,
working_dir,
config.settings.Server.ubridge_control_transport,
config.settings.Server.host,
str(uuid.uuid4()),
)
name = f"gvx{uuid.uuid4().hex[:8]}"
# VNI in the deployment's own derived range (utils.vxlan) so the probe
# exercises exactly the kernel path real links use.
vni = VXLAN_VNI_MIN + uuid.uuid4().int % (VXLAN_VNI_MAX - VXLAN_VNI_MIN + 1)
dstport = 20000 + uuid.uuid4().int % 10000
# The scratch socket must never be the configured dstport: on it the
# scratch VNI shares a demux table with the live links' devices, and an
# unlucky draw would make the probe report the capability unknown for
# the process's lifetime (the result is cached).
if dstport == config.settings.Server.vxlan_dstport:
dstport = 20000 if dstport == 29999 else dstport + 1
try:
async def ask():
await hypervisor.start()
await hypervisor.connect()
await hypervisor.send(f"vxlan create {name} {vni} remote=127.0.0.1 dstport={dstport}")
try:
await hypervisor.send(f"vxlan delete {name}")
except UbridgeError as e:
log.warning("Probe VXLAN %s could not be deleted: %s", name, e)
return True
try:
return await asyncio.wait_for(ask(), timeout=timeout)
except (UbridgeError, OSError, asyncio.TimeoutError, ValueError) as e:
# Old build ("Unknown command"), missing module, or no
# CAP_NET_ADMIN: unknown.
log.debug("uBridge vxlan-module probe failed: %s", e)
return None
finally:
with contextlib.suppress(Exception):
await hypervisor.stop()
shutil.rmtree(working_dir, ignore_errors=True)