mirror of
https://github.com/GNS3/gns3-server.git
synced 2026-11-05 06:41:13 +02:00
Merge branch '3.1' into 3.1
This commit is contained in:
51 files changed
+3155
-183
No files matched your search
@@ -30,6 +30,7 @@ from gns3server.compute.ubridge.tc_probe import (
|
||||
probe_iol_tap_support,
|
||||
probe_tap_support,
|
||||
probe_tc_capabilities,
|
||||
probe_vxlan_support,
|
||||
)
|
||||
from gns3server.compute.vpcs.vpcs_probe import probe_vpcs_tap
|
||||
from gns3server.utils.path import get_default_project_directory
|
||||
@@ -75,6 +76,11 @@ async def get_capabilities(request: Request) -> dict:
|
||||
# kernel link. None = unknown (probe failed or binary missing).
|
||||
vpcs_tap = await probe_vpcs_tap()
|
||||
|
||||
# VXLAN device creation (the vxlan module): the tunnel a cross-compute
|
||||
# kernel link rides. None = unknown (probe failed, uBridge too old, or
|
||||
# the kernel refuses vxlan devices here).
|
||||
ubridge_vxlan = await probe_vxlan_support()
|
||||
|
||||
# record the controller hostname or IP address
|
||||
if request.client:
|
||||
request.app.state.controller_host = request.client.host
|
||||
@@ -91,4 +97,5 @@ async def get_capabilities(request: Request) -> dict:
|
||||
"ubridge_iol_tap": ubridge_iol_tap,
|
||||
"ubridge_bridge_tap": ubridge_bridge_tap,
|
||||
"vpcs_tap": vpcs_tap,
|
||||
"ubridge_vxlan": ubridge_vxlan,
|
||||
}
|
||||
@@ -35,6 +35,7 @@ from fastapi.encoders import jsonable_encoder
|
||||
from fastapi.responses import FileResponse
|
||||
|
||||
from gns3server import schemas
|
||||
from gns3server.compute.error import NodeError
|
||||
from gns3server.compute.project import Project
|
||||
from gns3server.compute.project_manager import ProjectManager
|
||||
from gns3server.utils.path import is_safe_path
|
||||
@@ -271,16 +272,35 @@ async def create_batch_nios(
|
||||
# count (standard == 1, Dynamips == 2) and await the async variant.
|
||||
sig = inspect.signature(node.manager.create_nio)
|
||||
dynamips_style = len(sig.parameters) >= 2
|
||||
failed = []
|
||||
for entry in entries:
|
||||
nio_settings = jsonable_encoder(entry.nio, exclude_unset=True)
|
||||
if dynamips_style:
|
||||
nio = await node.manager.create_nio(node, nio_settings)
|
||||
else:
|
||||
nio = node.manager.create_nio(nio_settings)
|
||||
await _add_nio_binding(node, entry.adapter_number, entry.port_number, nio)
|
||||
try:
|
||||
if dynamips_style:
|
||||
nio = await node.manager.create_nio(node, nio_settings)
|
||||
else:
|
||||
nio = node.manager.create_nio(nio_settings)
|
||||
await _add_nio_binding(node, entry.adapter_number, entry.port_number, nio)
|
||||
except NodeError:
|
||||
# A VXLAN entry that cannot be wired (its VNI already
|
||||
# registered on the host's shared socket, no route to the
|
||||
# peer) must not fail the whole project open: report it so
|
||||
# the controller can degrade that link to the uBridge relay.
|
||||
# Every other NIO type keeps the fail-the-batch semantics.
|
||||
if not nio_settings.get("vxlan"):
|
||||
raise
|
||||
failed.append(
|
||||
{
|
||||
"node_id": entry.node_id,
|
||||
"adapter_number": entry.adapter_number,
|
||||
"port_number": entry.port_number,
|
||||
}
|
||||
)
|
||||
return failed
|
||||
|
||||
await asyncio.gather(*[_create_one_node(nid, ents) for nid, ents in per_node.items()])
|
||||
return {"added": len(batch.nios)}
|
||||
results = await asyncio.gather(*[_create_one_node(nid, ents) for nid, ents in per_node.items()])
|
||||
failed = [item for node_failed in results for item in node_failed]
|
||||
return {"added": len(batch.nios) - len(failed), "failed": failed}
|
||||
|
||||
|
||||
@router.put(
|
||||
|
||||
@@ -135,6 +135,10 @@ async def update_qemu_node(node_data: schemas.QemuUpdate, node: QemuVM = Depends
|
||||
"""
|
||||
|
||||
data = jsonable_encoder(node_data, exclude_unset=True)
|
||||
# Reject interface changes before applying any other property or changing live media.
|
||||
for name in ("cdrom_interface", "cdrom2_interface"):
|
||||
if name in data:
|
||||
node.validate_cdrom_interface(name, data[name])
|
||||
# update the console first to avoid issue if updating console type
|
||||
node.console = data.pop("console", node.console)
|
||||
for name, value in data.items():
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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}"')
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
@@ -207,6 +207,7 @@ async def _patch_project_file(
|
||||
|
||||
# image files that we need to include in the exported archive
|
||||
images = []
|
||||
portable_references = []
|
||||
|
||||
try:
|
||||
with open(path) as f:
|
||||
@@ -248,32 +249,54 @@ async def _patch_project_file(
|
||||
if value is None or value.strip() == "":
|
||||
continue
|
||||
|
||||
if not keep_compute_ids: # If we keep the original compute we can keep the image path
|
||||
if not keep_compute_ids:
|
||||
node["properties"][prop] = os.path.basename(value)
|
||||
|
||||
if include_images is True:
|
||||
images.append({"compute_id": compute_id, "image": value, "image_type": node["node_type"]})
|
||||
if include_images:
|
||||
image = {"compute_id": compute_id, "image": value, "image_type": node["node_type"]}
|
||||
images.append(image)
|
||||
if not keep_compute_ids:
|
||||
portable_references.append((node["properties"], prop, image))
|
||||
|
||||
if not keep_compute_ids:
|
||||
topology["topology"][
|
||||
"computes"
|
||||
] = [] # Strip compute information because could contain secret info like password
|
||||
|
||||
local_images = {i["image"] for i in images if i["compute_id"] == "local"}
|
||||
destinations = _image_export_names(images)
|
||||
for properties, prop, image in portable_references:
|
||||
properties[prop] = destinations[(image["compute_id"], image["image_type"], image["image"])]
|
||||
|
||||
for image in local_images:
|
||||
_export_local_image(image, zstream)
|
||||
|
||||
remote_images = {(i["compute_id"], i["image_type"], i["image"]) for i in images if i["compute_id"] != "local"}
|
||||
|
||||
for compute_id, image_type, image in remote_images:
|
||||
await _export_remote_images(project, compute_id, image_type, image, zstream, temporary_dir)
|
||||
for (compute_id, image_type, image), export_name in destinations.items():
|
||||
if compute_id == "local":
|
||||
_export_local_image(image, zstream, export_name, image_type)
|
||||
else:
|
||||
await _export_remote_images(project, compute_id, image_type, image, zstream, temporary_dir, export_name)
|
||||
|
||||
zstream.writestr("project.gns3", json.dumps(topology, indent=4, sort_keys=True).encode())
|
||||
return images
|
||||
|
||||
|
||||
def _export_local_image(image, zstream):
|
||||
def _image_export_names(images):
|
||||
"""Allocate distinct portable names without changing ordinary image names."""
|
||||
destinations = {}
|
||||
used = set()
|
||||
for image in images:
|
||||
key = (image["compute_id"], image["image_type"], image["image"])
|
||||
if key in destinations:
|
||||
continue
|
||||
filename = os.path.basename(image["image"])
|
||||
stem, extension = os.path.splitext(filename)
|
||||
index = 1
|
||||
while (image["image_type"], filename) in used:
|
||||
index += 1
|
||||
filename = f"{stem}-{index}{extension}"
|
||||
used.add((image["image_type"], filename))
|
||||
destinations[key] = filename
|
||||
return destinations
|
||||
|
||||
|
||||
def _export_local_image(image, zstream, export_name=None, image_type=None):
|
||||
"""
|
||||
Exports a local image to the zip file.
|
||||
|
||||
@@ -284,6 +307,8 @@ def _export_local_image(image, zstream):
|
||||
from ..compute import MODULES
|
||||
|
||||
for module in MODULES:
|
||||
if image_type and getattr(module, "_NODE_TYPE", None) != image_type:
|
||||
continue
|
||||
try:
|
||||
images_directory = module.instance().get_images_directory()
|
||||
except NotImplementedError:
|
||||
@@ -297,13 +322,15 @@ def _export_local_image(image, zstream):
|
||||
path = os.path.join(images_directory, image)
|
||||
|
||||
if os.path.exists(path):
|
||||
arcname = os.path.join("images", directory, os.path.basename(image))
|
||||
arcname = os.path.join("images", directory, export_name or os.path.basename(image))
|
||||
_patch_mtime(path)
|
||||
zstream.write(path, arcname)
|
||||
return
|
||||
|
||||
|
||||
async def _export_remote_images(project, compute_id, image_type, image, project_zipfile, temporary_dir):
|
||||
async def _export_remote_images(
|
||||
project, compute_id, image_type, image, project_zipfile, temporary_dir, export_name=None
|
||||
):
|
||||
"""
|
||||
Export specific image from remote compute.
|
||||
"""
|
||||
@@ -333,5 +360,6 @@ async def _export_remote_images(project, compute_id, image_type, image, project_
|
||||
break
|
||||
await f.write(data)
|
||||
response.close()
|
||||
arcname = os.path.join("images", image_type, image)
|
||||
directory = "QEMU" if image_type == "qemu" else image_type
|
||||
arcname = os.path.join("images", directory, export_name or image)
|
||||
project_zipfile.write(temp_path, arcname=arcname, compress_type=zipfile.ZIP_DEFLATED)
|
||||
@@ -51,9 +51,17 @@ merge sorts by ``(ts, source file, frame number)`` and index structures must
|
||||
never use ts alone as a dict key, or same-microsecond frames silently
|
||||
overwrite each other. The canonical ts strings travel to clients verbatim
|
||||
and must be round-tripped verbatim.
|
||||
|
||||
Capture files live on the compute hosting the capture node — only a local
|
||||
marker's pcap sits in the controller's project directory. A remote marker's
|
||||
pcap is fetched from its compute over HTTP on every acquire into a bounded
|
||||
cache (paused marker pcaps hold matched packets only, so they stay small);
|
||||
the previous copy is kept when the bytes are identical so resident sharkd
|
||||
sessions (validated by ``(mtime, size)``) survive unchanged refetches.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import filecmp
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
@@ -63,7 +71,9 @@ import tempfile
|
||||
import time
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
from .controller_error import ControllerBadRequestError, ControllerError, ControllerNotFoundError
|
||||
import aiofiles
|
||||
|
||||
from .controller_error import ComputeError, ControllerBadRequestError, ControllerError, ControllerNotFoundError
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
@@ -202,6 +212,173 @@ def read_frame_bytes(path, frame_number):
|
||||
return f.read(incl_len).hex()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Remote capture sources (fetched from their compute over HTTP)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# Bounded number of remote marker pcaps kept on the controller. Only idle
|
||||
# entries are ever evicted (see _remote_pcap), so a request's own sources
|
||||
# cannot be pulled out from under it.
|
||||
_REMOTE_CACHE_MAX = 32
|
||||
|
||||
# (project_id, compute_id, rel_path) -> {"path": str, "last_used": float, "_uses": int}
|
||||
_remote_cache: dict[tuple[str, str, str], dict] = {}
|
||||
_remote_locks: dict[tuple[str, str, str], asyncio.Lock] = {}
|
||||
_remote_cache_dir: str | None = None
|
||||
|
||||
|
||||
def _cache_path(project, compute, rel_path):
|
||||
"""Flat, collision-free cache path: project/compute ids are uuids and
|
||||
basename() strips any path component out of the file name."""
|
||||
|
||||
global _remote_cache_dir
|
||||
if _remote_cache_dir is None:
|
||||
_remote_cache_dir = tempfile.mkdtemp(prefix="gns3-marker-replay-")
|
||||
os.makedirs(_remote_cache_dir, exist_ok=True)
|
||||
return os.path.join(_remote_cache_dir, f"{project.id}_{compute.id}_{os.path.basename(rel_path)}")
|
||||
|
||||
|
||||
def _drop_cached_file(key):
|
||||
"""Forget a cache entry and delete its file (marker removed on the compute)."""
|
||||
|
||||
entry = _remote_cache.pop(key, None)
|
||||
if entry and entry["path"]:
|
||||
try:
|
||||
os.remove(entry["path"])
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
def _evict_idle_remote_cache():
|
||||
"""Trim the cache down to the cap, least-recently-used first. Only idle
|
||||
entries are evictable; dict access is synchronous, so this cannot
|
||||
interleave with a concurrent acquire's refcount bump."""
|
||||
|
||||
while len(_remote_cache) > _REMOTE_CACHE_MAX:
|
||||
idle = [key for key, entry in _remote_cache.items() if entry["_uses"] == 0]
|
||||
if not idle:
|
||||
break
|
||||
victim = min(idle, key=lambda key: _remote_cache[key]["last_used"])
|
||||
entry = _remote_cache.pop(victim)
|
||||
if entry["path"]:
|
||||
try:
|
||||
os.remove(entry["path"])
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
async def _fetch_to_cache(project, compute, rel_path, path):
|
||||
"""
|
||||
Fetch the pcap from its compute, replacing the cached copy only when the
|
||||
bytes changed — an unchanged copy keeps its mtime, so the resident sharkd
|
||||
session keyed by that path stays valid. The file is refetched on every
|
||||
acquire: the gate already pins replay to paused markers, and a marker
|
||||
resumed and re-paused between two requests rebuilds its pcap.
|
||||
|
||||
:returns: the cache path, or None when the compute has no such file (the
|
||||
marker never matched anything — not an error).
|
||||
:raises ComputeError: the compute could not be reached or refused the file.
|
||||
"""
|
||||
|
||||
try:
|
||||
response = await compute.http_query("GET", f"/projects/{project.id}/files/{rel_path}", stream=True)
|
||||
except ControllerNotFoundError:
|
||||
return None
|
||||
except ControllerError as e:
|
||||
raise ComputeError(f"Could not fetch capture file from compute '{compute.id}': {e}") from e
|
||||
|
||||
tmp = f"{path}.tmp"
|
||||
try:
|
||||
async with aiofiles.open(tmp, "wb") as f:
|
||||
async for chunk in response.content.iter_any():
|
||||
if chunk:
|
||||
await f.write(chunk)
|
||||
await response.release()
|
||||
if os.path.exists(path) and filecmp.cmp(path, tmp, shallow=False):
|
||||
os.remove(tmp) # unchanged: keep the old copy, mtime intact
|
||||
else:
|
||||
os.replace(tmp, path)
|
||||
finally:
|
||||
if os.path.exists(tmp):
|
||||
try:
|
||||
os.remove(tmp)
|
||||
except OSError:
|
||||
pass
|
||||
return path
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def _remote_pcap(project, compute, rel_path):
|
||||
"""
|
||||
A controller-local copy of a capture file living on a remote compute.
|
||||
Acquires of the same file are serialized per key; the entry is
|
||||
refcounted while yielded so eviction only ever removes idle files.
|
||||
|
||||
Yields the local path, or None when the compute has no such file.
|
||||
"""
|
||||
|
||||
key = (project.id, compute.id, rel_path)
|
||||
lock = _remote_locks.setdefault(key, asyncio.Lock())
|
||||
async with lock:
|
||||
entry = _remote_cache.get(key)
|
||||
if entry is None:
|
||||
entry = {"path": _cache_path(project, compute, rel_path), "last_used": 0.0, "_uses": 0}
|
||||
_remote_cache[key] = entry
|
||||
entry["_uses"] += 1
|
||||
try:
|
||||
path = await _fetch_to_cache(project, compute, rel_path, entry["path"])
|
||||
entry["last_used"] = time.monotonic()
|
||||
if path is None:
|
||||
_drop_cached_file(key)
|
||||
yield path
|
||||
finally:
|
||||
entry["_uses"] -= 1
|
||||
_evict_idle_remote_cache()
|
||||
|
||||
|
||||
def _capture_node_compute(project, node_id):
|
||||
"""
|
||||
The compute hosting a marker's capture node, or None when the capture is
|
||||
local — or the node is gone, in which case only the controller's own
|
||||
markers directory can hold a copy and the caller has already checked it.
|
||||
"""
|
||||
|
||||
get_node = getattr(project, "get_node", None)
|
||||
if get_node is None:
|
||||
return None
|
||||
try:
|
||||
compute = get_node(node_id).compute
|
||||
except ControllerError:
|
||||
return None
|
||||
if compute is None or compute.id == "local":
|
||||
return None
|
||||
return compute
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def _source_pcap(project, node_id, link_id, marker):
|
||||
"""
|
||||
Resolve a marker's capture file to a controller-local path.
|
||||
|
||||
Yields None when no capture exists (no packet ever matched on that
|
||||
marker). Local markers resolve straight to the project's markers
|
||||
directory; a marker whose capture node runs on another compute is
|
||||
fetched from that compute (see _remote_pcap).
|
||||
"""
|
||||
|
||||
filename = f"{node_id}_{link_id}_{marker}.pcap"
|
||||
path = os.path.join(project.markers_directory, filename)
|
||||
if os.path.exists(path):
|
||||
yield path
|
||||
return
|
||||
compute = _capture_node_compute(project, node_id)
|
||||
if compute is None:
|
||||
yield None
|
||||
return
|
||||
async with _remote_pcap(project, compute, f"project-files/markers/{filename}") as fetched:
|
||||
yield fetched
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# sharkd: process environment and scratch copies
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -526,13 +703,19 @@ def _get_manager():
|
||||
|
||||
async def close_sessions():
|
||||
"""Server shutdown hook: kill every resident session and drop its scratch
|
||||
copy, so nothing leaks into /tmp across restarts."""
|
||||
copy, and remove the remote-capture cache, so nothing leaks into /tmp
|
||||
across restarts."""
|
||||
|
||||
global _manager
|
||||
global _remote_cache_dir
|
||||
manager = _manager
|
||||
if manager is not None:
|
||||
await manager.close_all()
|
||||
_manager = None
|
||||
if _remote_cache_dir is not None:
|
||||
shutil.rmtree(_remote_cache_dir, ignore_errors=True)
|
||||
_remote_cache_dir = None
|
||||
_remote_cache.clear()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -655,45 +838,68 @@ async def _merged_frames(project, entries, filter_expr=None, link_id=None):
|
||||
``sources`` is the stable inventory of the tag: EVERY capture source is
|
||||
listed with engine-free total counts, unaffected by ``link_id`` /
|
||||
``filter_expr`` — the inventory must not shrink when the view narrows.
|
||||
A capture file that cannot be fetched from its compute keeps its source
|
||||
entry, with ``count: 0`` and an ``error`` message, instead of silently
|
||||
vanishing from the timeline.
|
||||
"""
|
||||
|
||||
markers_dir = project.markers_directory
|
||||
merged = []
|
||||
sources = []
|
||||
for entry in entries:
|
||||
pcap = os.path.join(markers_dir, f"{entry['node_id']}_{entry['link_id']}_{entry['marker']}.pcap")
|
||||
frames = scan_pcap_frames(pcap) if os.path.exists(pcap) else []
|
||||
# Inventory first: every source, engine-free totals.
|
||||
sources.append(
|
||||
{**{k: entry[k] for k in ("node_id", "link_id", "marker", "data_link_type")}, "count": len(frames)}
|
||||
)
|
||||
if link_id and entry["link_id"] != link_id:
|
||||
continue # link narrows the stream before any engine work
|
||||
if frames:
|
||||
columns = await _columns_for(pcap, filter_expr)
|
||||
else:
|
||||
columns = {}
|
||||
source_key = f"{entry['node_id']}_{entry['link_id']}_{entry['marker']}"
|
||||
for frame_number, (sec, usec, incl_len) in enumerate(frames, start=1):
|
||||
if filter_expr is not None and frame_number not in columns:
|
||||
continue
|
||||
cols = columns.get(frame_number, {})
|
||||
merged.append(
|
||||
try:
|
||||
async with _source_pcap(project, entry["node_id"], entry["link_id"], entry["marker"]) as pcap:
|
||||
frames = scan_pcap_frames(pcap) if pcap else []
|
||||
# Inventory first: every source, engine-free totals.
|
||||
sources.append(
|
||||
{
|
||||
**{k: entry[k] for k in ("node_id", "link_id", "marker", "data_link_type")},
|
||||
"count": len(frames),
|
||||
}
|
||||
)
|
||||
if link_id and entry["link_id"] != link_id:
|
||||
continue # link narrows the stream before any engine work
|
||||
if frames:
|
||||
columns = await _columns_for(pcap, filter_expr)
|
||||
else:
|
||||
columns = {}
|
||||
source_key = f"{entry['node_id']}_{entry['link_id']}_{entry['marker']}"
|
||||
for frame_number, (sec, usec, incl_len) in enumerate(frames, start=1):
|
||||
if filter_expr is not None and frame_number not in columns:
|
||||
continue
|
||||
cols = columns.get(frame_number, {})
|
||||
merged.append(
|
||||
{
|
||||
"ts": _format_ts(sec, usec),
|
||||
"ts_us": sec * 1_000_000 + usec,
|
||||
"_source": source_key,
|
||||
"len": incl_len,
|
||||
"node_id": entry["node_id"],
|
||||
"link_id": entry["link_id"],
|
||||
"marker": entry["marker"],
|
||||
"frame_number": frame_number,
|
||||
"src": cols.get("src"),
|
||||
"dst": cols.get("dst"),
|
||||
"proto": cols.get("proto"),
|
||||
"info": cols.get("info"),
|
||||
"bg": cols.get("bg"),
|
||||
"fg": cols.get("fg"),
|
||||
}
|
||||
)
|
||||
except ComputeError as e:
|
||||
# The capture file lives on a compute that could not be reached:
|
||||
# keep the source in the inventory, flagged, so a partial timeline
|
||||
# is never mistaken for the whole capture.
|
||||
log.warning(
|
||||
"Capture file of marker '%s' on link %s could not be fetched: %s",
|
||||
entry["marker"],
|
||||
entry["link_id"],
|
||||
e,
|
||||
)
|
||||
sources.append(
|
||||
{
|
||||
"ts": _format_ts(sec, usec),
|
||||
"ts_us": sec * 1_000_000 + usec,
|
||||
"_source": source_key,
|
||||
"len": incl_len,
|
||||
"node_id": entry["node_id"],
|
||||
"link_id": entry["link_id"],
|
||||
"marker": entry["marker"],
|
||||
"frame_number": frame_number,
|
||||
"src": cols.get("src"),
|
||||
"dst": cols.get("dst"),
|
||||
"proto": cols.get("proto"),
|
||||
"info": cols.get("info"),
|
||||
"bg": cols.get("bg"),
|
||||
"fg": cols.get("fg"),
|
||||
**{k: entry[k] for k in ("node_id", "link_id", "marker", "data_link_type")},
|
||||
"count": 0,
|
||||
"error": str(e),
|
||||
}
|
||||
)
|
||||
merged.sort(key=lambda f: (f["ts_us"], f["_source"], f["frame_number"]))
|
||||
@@ -761,7 +967,10 @@ async def decode_frame(project, tag, ts, node_id, link_id, marker, frame_number=
|
||||
round-tripped ts still matches the file (guards a rebuild between the
|
||||
timeline view and this click), read the raw bytes for the hex view
|
||||
straight from the pcap, and rename the sharkd protocol tree into the
|
||||
REST contract (closed key set, values untouched).
|
||||
REST contract (closed key set, values untouched). A marker captured on
|
||||
another compute decodes from its fetched cache copy; a compute that
|
||||
cannot be reached raises ComputeError — distinct from the 404s below,
|
||||
which mean the marker never matched anything or the capture was rebuilt.
|
||||
|
||||
``ts`` is not unique within one pcap (same-microsecond frames are kept
|
||||
deliberately); an explicit ``frame_number`` from the frame list entry
|
||||
@@ -778,40 +987,40 @@ async def decode_frame(project, tag, ts, node_id, link_id, marker, frame_number=
|
||||
if entry is None:
|
||||
raise ControllerNotFoundError(f"No marker '{marker}' with tag {tag} on link {link_id} captured by {node_id}")
|
||||
|
||||
pcap = os.path.join(project.markers_directory, f"{node_id}_{link_id}_{marker}.pcap")
|
||||
if not os.path.exists(pcap):
|
||||
raise ControllerNotFoundError(f"No capture file for marker '{marker}' (nothing ever matched)")
|
||||
async with _source_pcap(project, node_id, link_id, marker) as pcap:
|
||||
if pcap is None:
|
||||
raise ControllerNotFoundError(f"No capture file for marker '{marker}' (nothing ever matched)")
|
||||
|
||||
frames = scan_pcap_frames(pcap)
|
||||
rebuilt_message = f"No frame at ts {ts} in marker '{marker}' (the capture may have been rebuilt)"
|
||||
if frame_number is None:
|
||||
# The ts must be the exact string the timeline returned; find the frame
|
||||
# it identifies rather than trusting any position hint from the client.
|
||||
frame_number = next(
|
||||
(i for i, (sec, usec, _len) in enumerate(frames, start=1) if _format_ts(sec, usec) == ts),
|
||||
None,
|
||||
)
|
||||
frames = scan_pcap_frames(pcap)
|
||||
rebuilt_message = f"No frame at ts {ts} in marker '{marker}' (the capture may have been rebuilt)"
|
||||
if frame_number is None:
|
||||
raise ControllerNotFoundError(rebuilt_message)
|
||||
else:
|
||||
if not 1 <= frame_number <= len(frames):
|
||||
raise ControllerNotFoundError(rebuilt_message)
|
||||
sec, usec, _len = frames[frame_number - 1]
|
||||
if _format_ts(sec, usec) != ts:
|
||||
# The ts must be the exact string the timeline returned; find the frame
|
||||
# it identifies rather than trusting any position hint from the client.
|
||||
frame_number = next(
|
||||
(i for i, (sec, usec, _len) in enumerate(frames, start=1) if _format_ts(sec, usec) == ts),
|
||||
None,
|
||||
)
|
||||
if frame_number is None:
|
||||
raise ControllerNotFoundError(rebuilt_message)
|
||||
else:
|
||||
if not 1 <= frame_number <= len(frames):
|
||||
raise ControllerNotFoundError(rebuilt_message)
|
||||
sec, usec, _len = frames[frame_number - 1]
|
||||
if _format_ts(sec, usec) != ts:
|
||||
raise ControllerNotFoundError(rebuilt_message)
|
||||
|
||||
raw_hex = read_frame_bytes(pcap, frame_number)
|
||||
if raw_hex is None:
|
||||
# The header scan listed it but the bytes are gone — mid-write tail.
|
||||
raise ControllerNotFoundError(rebuilt_message)
|
||||
|
||||
raw_hex = read_frame_bytes(pcap, frame_number)
|
||||
if raw_hex is None:
|
||||
# The header scan listed it but the bytes are gone — mid-write tail.
|
||||
raise ControllerNotFoundError(rebuilt_message)
|
||||
|
||||
async with _get_manager().session(pcap) as session:
|
||||
try:
|
||||
result = await session.rpc("frame", {"frame": frame_number, "proto": True})
|
||||
except _SharkdRpcError as e:
|
||||
# The frame range was validated against the file above, so an
|
||||
# engine error here is a real fault (502), not a client 404.
|
||||
raise SharkdError(f"sharkd frame failed: {e.message}")
|
||||
async with _get_manager().session(pcap) as session:
|
||||
try:
|
||||
result = await session.rpc("frame", {"frame": frame_number, "proto": True})
|
||||
except _SharkdRpcError as e:
|
||||
# The frame range was validated against the file above, so an
|
||||
# engine error here is a real fault (502), not a client 404.
|
||||
raise SharkdError(f"sharkd frame failed: {e.message}")
|
||||
|
||||
tree = _rename_value(result.get("tree", []))
|
||||
return {
|
||||
|
||||
@@ -20,18 +20,33 @@ import copy
|
||||
import html
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import uuid
|
||||
|
||||
from ..config import Config
|
||||
from ..utils import int_to_macaddress, macaddress_to_int
|
||||
from ..utils.application_id import is_iol_runner_environment
|
||||
from ..utils.images import images_directories
|
||||
from ..utils.vxlan import vxlan_device_name
|
||||
from .controller_error import ComputeConflictError, ComputeError, ControllerError, ControllerTimeoutError
|
||||
from .node_types import BUILTIN_NODE_TYPES
|
||||
from .ports.port_factory import DynamipsPortFactory, PortFactory, StandardPortFactory
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
# The compute's error when it could not create a VXLAN tunnel device — the
|
||||
# wording is a same-repo contract with KernelDatapathMixin._kernel_error
|
||||
# (only computes running this codebase carry VXLAN links: the capability
|
||||
# gate keeps everything else on the relay), and the captured device name
|
||||
# identifies the link (a pure function of link id + node id).
|
||||
_VXLAN_CONFLICT_RE = re.compile(r"Could not create VXLAN device (?P<device>\S+)")
|
||||
|
||||
# Node-start self-heal rounds over a refused VXLAN VNI: the first re-wires
|
||||
# the link on a fresh VNI, the second degrades it to the relay (whose NIOs
|
||||
# cannot hit the conflict) — a start must not fail permanently because
|
||||
# another link already registered the VNI on the shared dstport socket.
|
||||
_VXLAN_START_HEALS = 2
|
||||
|
||||
|
||||
# Image properties of a node, mapped by node type. The value is the image
|
||||
# "type" used by the image manager (see utils.images). Docker images are not
|
||||
@@ -43,6 +58,7 @@ IMAGE_PROPERTIES_BY_NODE_TYPE = {
|
||||
"hdc_disk_image": "qemu",
|
||||
"hdd_disk_image": "qemu",
|
||||
"cdrom_image": "qemu",
|
||||
"cdrom2_image": "qemu",
|
||||
"initrd": "qemu",
|
||||
"kernel_image": "qemu",
|
||||
"bios_image": "qemu",
|
||||
@@ -885,6 +901,7 @@ class Node:
|
||||
"hdc_disk_image",
|
||||
"hdd_disk_image",
|
||||
"cdrom_image",
|
||||
"cdrom2_image",
|
||||
"bios_image",
|
||||
"initrd",
|
||||
"kernel_image",
|
||||
@@ -955,18 +972,32 @@ class Node:
|
||||
f"Cannot start node '{self._name}': {len(failed_links)} deferred link(s) "
|
||||
"could not be restored. Please try again."
|
||||
)
|
||||
try:
|
||||
# For IOU: we need to send the licence everytime we start a node
|
||||
if self.node_type == "iou":
|
||||
license_check = self._project.controller.iou_license.get("license_check", True)
|
||||
iourc_content = self._project.controller.iou_license.get("iourc_content", None)
|
||||
await self.post(
|
||||
"/start", timeout=240, data={"license_check": license_check, "iourc_content": iourc_content}
|
||||
for heal in range(_VXLAN_START_HEALS + 1):
|
||||
try:
|
||||
await self._start_on_compute(data)
|
||||
break
|
||||
except asyncio.TimeoutError:
|
||||
raise ControllerTimeoutError(f"Timeout when starting {self._name}")
|
||||
except ComputeError as e:
|
||||
conflicted = self._vxlan_conflicted_link(e)
|
||||
if conflicted is None or heal == _VXLAN_START_HEALS:
|
||||
raise
|
||||
degrade = heal > 0 # a second conflict takes the relay
|
||||
log.warning(
|
||||
"Node '%s': start refused over a VXLAN VNI conflict on link %s — %s and retrying",
|
||||
self._name,
|
||||
conflicted.id,
|
||||
"degrading the link to the uBridge relay" if degrade else "re-wiring the link on a fresh VNI",
|
||||
)
|
||||
else:
|
||||
await self.post("/start", data=data, timeout=240)
|
||||
except TimeoutError:
|
||||
raise ControllerTimeoutError(f"Timeout when starting {self._name}")
|
||||
# The refused start leaves the node half-started (the
|
||||
# emulator process runs, the wiring partial) — stop it
|
||||
# first: a start on a running container early-returns
|
||||
# without wiring anything.
|
||||
await self.stop()
|
||||
if await self._project._redispatch_vxlan_links([conflicted], downgrade=degrade) and not degrade:
|
||||
# The fresh VNI was already refused before the start
|
||||
# could even retry — degrade the pair to the relay now.
|
||||
await self._project._redispatch_vxlan_links([conflicted], downgrade=True)
|
||||
self._confirm_status("started")
|
||||
# A started node may have brought kernel anchors into existence that
|
||||
# a link needs to finish wiring (the Ethernet-switch fast path joins
|
||||
@@ -974,6 +1005,42 @@ class Node:
|
||||
for link in self._links:
|
||||
await link.node_started(self)
|
||||
|
||||
async def _start_on_compute(self, data=None):
|
||||
"""
|
||||
The compute-side start request (IOU carries its licence along).
|
||||
"""
|
||||
|
||||
# For IOU: we need to send the licence everytime we start a node
|
||||
if self.node_type == "iou":
|
||||
license_check = self._project.controller.iou_license.get("license_check", True)
|
||||
iourc_content = self._project.controller.iou_license.get("iourc_content", None)
|
||||
await self.post(
|
||||
"/start", timeout=240, data={"license_check": license_check, "iourc_content": iourc_content}
|
||||
)
|
||||
else:
|
||||
await self.post("/start", data=data, timeout=240)
|
||||
|
||||
def _vxlan_conflicted_link(self, error):
|
||||
"""
|
||||
The link whose VXLAN device a failed node start could not create,
|
||||
or None for every other failure.
|
||||
|
||||
The compute's error names the device it could not create and the
|
||||
name is a pure function of (link id, node id), so it identifies the
|
||||
link with no extra API surface. An unmatched message — another
|
||||
failure, a future wording change — simply leaves the error to
|
||||
surface unchanged, which is exactly the behaviour without the heal.
|
||||
"""
|
||||
|
||||
match = _VXLAN_CONFLICT_RE.search(str(error))
|
||||
if match is None:
|
||||
return None
|
||||
device = match.group("device")
|
||||
for link in self._links:
|
||||
if vxlan_device_name(link.id, self.id) == device and any(nio.get("vxlan") for nio in link._link_data):
|
||||
return link
|
||||
return None
|
||||
|
||||
async def stop(self, strict=False):
|
||||
"""
|
||||
Stop a node
|
||||
|
||||
@@ -49,7 +49,7 @@ from .link import _UNSET
|
||||
from .node import Node
|
||||
from .snapshot import Snapshot
|
||||
from .topology import load_topology, project_to_topology
|
||||
from .udp_link import UDPLink
|
||||
from .udp_link import _VXLAN_ATTEMPTS, UDPLink
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
@@ -1021,6 +1021,84 @@ class Project:
|
||||
entries = await link._prepare()
|
||||
return (link, entries)
|
||||
|
||||
async def _dispatch_nio_batch(self, compute, nio_entries):
|
||||
"""
|
||||
POST one compute's NIO batch for this project, returning the entries
|
||||
it refused (VXLAN entries a compute could not wire — see the batch
|
||||
route; other NIO types fail the whole request).
|
||||
"""
|
||||
|
||||
response = await compute.post(
|
||||
f"/projects/{self._id}/nios/batch",
|
||||
data={"nios": nio_entries},
|
||||
timeout=300,
|
||||
)
|
||||
return response.json.get("failed") or []
|
||||
|
||||
async def _redispatch_vxlan_links(self, links, downgrade):
|
||||
"""
|
||||
Re-prepare *links* and dispatch their NIOs in a second per-compute
|
||||
batch, returning the links the computes still refused.
|
||||
|
||||
With ``downgrade=False`` each link's VNI salt is bumped first — a
|
||||
fresh VNI, since the refused one was already registered on the
|
||||
host's shared dstport socket. With ``downgrade=True`` the links are
|
||||
marked degraded to the uBridge relay (whose UDP NIOs cannot fail
|
||||
this way) before re-preparing.
|
||||
"""
|
||||
|
||||
for link in links:
|
||||
if downgrade:
|
||||
log.warning(
|
||||
"Project '%s' [%s]: VXLAN wiring failed for link %s — falling back to the uBridge relay datapath",
|
||||
self._name,
|
||||
self._id,
|
||||
link.id,
|
||||
)
|
||||
link._vxlan_downgraded = True
|
||||
else:
|
||||
link._vxlan_salt += 1
|
||||
per_compute = {}
|
||||
by_key = {}
|
||||
for link in links:
|
||||
for node, adapter_number, port_number, nio_data in await link._prepare():
|
||||
by_key[(str(node.id), adapter_number, port_number)] = link
|
||||
per_compute.setdefault(node.compute, []).append(
|
||||
{
|
||||
"node_id": node.id,
|
||||
"adapter_number": adapter_number,
|
||||
"port_number": port_number,
|
||||
"nio": nio_data,
|
||||
}
|
||||
)
|
||||
if not per_compute:
|
||||
return []
|
||||
results = await asyncio.gather(
|
||||
*[self._dispatch_nio_batch(compute, entries) for compute, entries in per_compute.items()]
|
||||
)
|
||||
failed = []
|
||||
for item in (i for sub in results for i in sub):
|
||||
link = by_key.get((str(item["node_id"]), item["adapter_number"], item["port_number"]))
|
||||
if link is not None and link not in failed:
|
||||
failed.append(link)
|
||||
return failed
|
||||
|
||||
async def _heal_refused_vxlan_links(self, pending):
|
||||
"""
|
||||
Drive the links a compute refused at open onto a working datapath:
|
||||
fresh-VNI rounds first — the same candidate count an interactive
|
||||
link creation tries — then, for whatever still fails, the
|
||||
degradation to the uBridge relay (whose UDP NIOs cannot hit this
|
||||
refusal).
|
||||
"""
|
||||
|
||||
for _ in range(_VXLAN_ATTEMPTS - 1):
|
||||
if not pending:
|
||||
return
|
||||
pending = await self._redispatch_vxlan_links(pending, downgrade=False)
|
||||
if pending:
|
||||
await self._redispatch_vxlan_links(pending, downgrade=True)
|
||||
|
||||
async def restore_deferred_links(self, node):
|
||||
"""
|
||||
Create on the computes the NIOs of the links that were deferred while
|
||||
@@ -1994,15 +2072,30 @@ class Project:
|
||||
}
|
||||
)
|
||||
|
||||
async def _dispatch_batch(compute, nio_entries):
|
||||
await compute.post(
|
||||
f"/projects/{self._id}/nios/batch",
|
||||
data={"nios": nio_entries},
|
||||
timeout=300,
|
||||
)
|
||||
|
||||
if per_compute:
|
||||
await asyncio.gather(*[_dispatch_batch(c, n) for c, n in per_compute.items()])
|
||||
results = await asyncio.gather(
|
||||
*[self._dispatch_nio_batch(compute, entries) for compute, entries in per_compute.items()]
|
||||
)
|
||||
failed = [item for sub in results for item in sub]
|
||||
else:
|
||||
failed = []
|
||||
# A VXLAN entry a compute refused (its VNI already registered on
|
||||
# the host's shared socket, no route to the peer) must not fail
|
||||
# the whole open: the refused links are healed onto a working
|
||||
# datapath (fresh VNIs, then the relay) — see
|
||||
# _heal_refused_vxlan_links.
|
||||
if failed:
|
||||
by_key = {}
|
||||
for link, entries in valid:
|
||||
for node, adapter_number, port_number, nio_data in entries:
|
||||
if nio_data.get("vxlan"):
|
||||
by_key[(str(node.id), adapter_number, port_number)] = link
|
||||
pending = []
|
||||
for item in failed:
|
||||
link = by_key.get((str(item["node_id"]), item["adapter_number"], item["port_number"]))
|
||||
if link is not None and link not in pending:
|
||||
pending.append(link)
|
||||
await self._heal_refused_vxlan_links(pending)
|
||||
|
||||
# Finalise every link: wire node/port back-references, mark created,
|
||||
# notify clients, and apply project-level marker definitions.
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import uuid
|
||||
|
||||
from gns3server.config import Config
|
||||
from gns3server.utils.application_id import is_iol_runner_environment
|
||||
@@ -26,6 +27,7 @@ from gns3server.utils.packet_filter_validation import (
|
||||
split_kernel_only_features,
|
||||
validate_bpf_syntax,
|
||||
)
|
||||
from gns3server.utils.vxlan import derive_vni, vxlan_device_name
|
||||
|
||||
from .compute import ComputeError
|
||||
from .controller_error import ControllerError, ControllerNotFoundError
|
||||
@@ -52,6 +54,10 @@ _MARKER_CAPABLE_TYPES = frozenset(
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
# VXLAN wiring attempts per link creation (the first plus the salt-bumped
|
||||
# retries) before the link degrades to the uBridge relay.
|
||||
_VXLAN_ATTEMPTS = 3
|
||||
|
||||
|
||||
def _is_unix_socket_docker(node):
|
||||
"""
|
||||
@@ -92,6 +98,15 @@ class UDPLink(Link):
|
||||
# of its endpoints has a missing image. The NIO creation is retried
|
||||
# once the image is resolved.
|
||||
self._deferred = False
|
||||
# Cross-compute VXLAN wiring state: the salt is this link object's
|
||||
# starting point in the VNI space — random per object, so a project
|
||||
# reopen re-rolls every link's VNI and a link whose VNI collided
|
||||
# with an unrelated device on a host is not permanently unlucky
|
||||
# (the device *name*, which cleanup keys on, stays deterministic).
|
||||
# _commit_vxlan bumps it for an in-flight retry; the downgrade flag
|
||||
# marks a link degraded to the UDP relay for this object's lifetime.
|
||||
self._vxlan_salt = uuid.uuid4().int
|
||||
self._vxlan_downgraded = False
|
||||
|
||||
@property
|
||||
def debug_link_data(self):
|
||||
@@ -206,9 +221,13 @@ class UDPLink(Link):
|
||||
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
|
||||
touching the interfaces the node itself uses. Both endpoints must sit
|
||||
on computes that can carry their end: on one compute the ends meet in
|
||||
a per-link Linux bridge; across computes each end additionally rides
|
||||
its own VXLAN device (opt-in via enable_vxlan_cross_compute, uBridge's
|
||||
vxlan module) enslaved into the same per-link bridge, so everything
|
||||
below the bridge — filters, markers, capture, carrier — is identical
|
||||
either way. 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
|
||||
@@ -232,8 +251,21 @@ class UDPLink(Link):
|
||||
|
||||
if not Config.instance().settings.Server.enable_kernel_datapath:
|
||||
return False
|
||||
if node1.compute.id != node2.compute.id:
|
||||
return False
|
||||
cross_compute = node1.compute.id != node2.compute.id
|
||||
if cross_compute:
|
||||
# Cross-compute kernel links tunnel: each endpoint rides a VXLAN
|
||||
# device into the same per-link bridge the same-host datapath
|
||||
# uses. Opt-in (the tunnel caps the usable MTU at the underlay
|
||||
# minus the outer headers, so it pays off on jumbo fabrics) and
|
||||
# gated on both computes' uBridge being able to create the
|
||||
# device; a link that cannot tunnel stays on the relay, where it
|
||||
# always works. The downgrade flag is the runtime fallback: after
|
||||
# the kernel kept refusing this link's VNI, the link stays on the
|
||||
# relay for this object's lifetime.
|
||||
if not Config.instance().settings.Server.enable_vxlan_cross_compute:
|
||||
return False
|
||||
if self._vxlan_downgraded:
|
||||
return False
|
||||
if _is_unix_socket_docker(node1) or _is_unix_socket_docker(node2):
|
||||
return False
|
||||
for side in self._nodes:
|
||||
@@ -249,10 +281,17 @@ class UDPLink(Link):
|
||||
# pair is the only shape a bridge-to-bridge cable can take).
|
||||
switch = self._switch_endpoint()
|
||||
if switch is not None:
|
||||
if cross_compute:
|
||||
# The fast path is a one-host shape (the peer's anchor is
|
||||
# absorbed into the switch's bridge); a switch link across
|
||||
# computes stays on the relay.
|
||||
return False
|
||||
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)
|
||||
if cross_compute:
|
||||
return self._vxlan_endpoint_ready(node1) and self._vxlan_endpoint_ready(node2)
|
||||
return self._kernel_endpoint_ready(node1) and self._kernel_endpoint_ready(node2)
|
||||
|
||||
def _switch_endpoint(self):
|
||||
@@ -305,6 +344,23 @@ class UDPLink(Link):
|
||||
return capabilities.get("ubridge_tap") is True and capabilities.get("vpcs_tap") is True
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
def _vxlan_endpoint_ready(node):
|
||||
"""
|
||||
Whether a node can carry its end of a cross-compute VXLAN link: it
|
||||
must be able to anchor (the same per-node-type capability the
|
||||
same-host kernel datapath needs — its end of the tunnel is enslaved
|
||||
into a per-link bridge like any kernel link's) and its compute's
|
||||
uBridge must be able to create the VXLAN device the tunnel rides.
|
||||
Strict True on both, like the other capability gates: an unreported
|
||||
or failed probe keeps the link on the relay, where it always works.
|
||||
"""
|
||||
|
||||
if not UDPLink._kernel_endpoint_ready(node):
|
||||
return False
|
||||
capabilities = node.compute.capabilities or {}
|
||||
return capabilities.get("ubridge_vxlan") is True
|
||||
|
||||
async def _prepare(self):
|
||||
"""
|
||||
Local-only link setup: resolve peer addresses, reserve UDP ports and
|
||||
@@ -330,10 +386,77 @@ class UDPLink(Link):
|
||||
adapter_number2 = self._nodes[1]["adapter_number"]
|
||||
port_number2 = self._nodes[1]["port_number"]
|
||||
|
||||
if self._kernel_datapath_eligible(node1, node2):
|
||||
cross_compute = node1.compute.id != node2.compute.id
|
||||
# A cross-compute link needs the compute pair's addresses on both
|
||||
# datapaths (the VXLAN tunnel's outer endpoints, the relay's UDP
|
||||
# peers): resolve once, and let the no-common-subnet failure stay a
|
||||
# ControllerError either way — it is a deployment error, not a
|
||||
# datapath decision. Equal addresses mean both computes share one
|
||||
# host: the kernel shares one VXLAN socket per dstport, so a tunnel
|
||||
# pair cannot live there, and such links keep the relay, where the
|
||||
# two ends are told apart by their UDP ports.
|
||||
hosts = None
|
||||
if cross_compute:
|
||||
try:
|
||||
hosts = 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}")
|
||||
same_host = hosts is not None and hosts[0] == hosts[1]
|
||||
|
||||
if self._kernel_datapath_eligible(node1, node2) and not same_host:
|
||||
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 hosts is not None:
|
||||
# Cross-compute VXLAN: each end gets one VXLAN device
|
||||
# carrying the link's VNI, enslaved into the per-link kernel
|
||||
# bridge next to the node's anchor (see _kernel_attach on
|
||||
# the compute side). The controller derives name and VNI —
|
||||
# both ends must agree on the VNI and neither can ask the
|
||||
# other — and the compute resolves the underlay interface
|
||||
# locally from the peer address, so the kernel derives the
|
||||
# device MTU (underlay - 50) and the bridge reports the
|
||||
# honest "what this fabric can carry" value. A VNI the
|
||||
# kernel refuses as already registered on the shared
|
||||
# dstport socket surfaces as a commit failure; create()
|
||||
# retries with a bumped salt and degrades to the relay when
|
||||
# the retries run out.
|
||||
node1_host, node2_host = hosts
|
||||
dstport = Config.instance().settings.Server.vxlan_dstport
|
||||
vni = derive_vni(self._id, self._vxlan_salt)
|
||||
bridge_name = "gns3" + self._id.replace("-", "")[:11]
|
||||
self._link_data = [
|
||||
{
|
||||
"type": "nio_bridge",
|
||||
"bridge": bridge_name,
|
||||
"vxlan": {
|
||||
"name": vxlan_device_name(self._id, node1.id),
|
||||
"vni": vni,
|
||||
"remote": node2_host,
|
||||
"dstport": dstport,
|
||||
},
|
||||
"filters": node1_filters,
|
||||
"markers": node1_markers,
|
||||
"suspend": self._suspended,
|
||||
},
|
||||
{
|
||||
"type": "nio_bridge",
|
||||
"bridge": bridge_name,
|
||||
"vxlan": {
|
||||
"name": vxlan_device_name(self._id, node2.id),
|
||||
"vni": vni,
|
||||
"remote": node1_host,
|
||||
"dstport": dstport,
|
||||
},
|
||||
"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]),
|
||||
]
|
||||
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
|
||||
@@ -449,11 +572,16 @@ class UDPLink(Link):
|
||||
(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}")
|
||||
# Relay datapath: UDP tunnels between the computes' addresses —
|
||||
# already resolved for a cross-compute pair, resolved here otherwise
|
||||
# (a same-compute relay pair resolves to the host's own address on
|
||||
# both sides, distinguished by their UDP ports).
|
||||
if hosts is None:
|
||||
try:
|
||||
hosts = 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}")
|
||||
node1_host, node2_host = hosts
|
||||
|
||||
# Reserve a UDP port on both sides in parallel. Pre-allocated ports
|
||||
# (used during batch project loading) are popped from memory; otherwise
|
||||
@@ -537,17 +665,70 @@ class UDPLink(Link):
|
||||
raise errors[0]
|
||||
self._created = True
|
||||
|
||||
@staticmethod
|
||||
def _has_vxlan(entries):
|
||||
"""
|
||||
Whether any prepared NIO spec of this link carries a VXLAN tunnel.
|
||||
"""
|
||||
|
||||
return any(nio_data.get("vxlan") for _, _, _, nio_data in entries)
|
||||
|
||||
async def create(self):
|
||||
"""
|
||||
Create the link on the nodes (interactive path: prepare + commit).
|
||||
"""
|
||||
|
||||
entries = await self._prepare()
|
||||
await self._commit_nios(entries)
|
||||
if self._has_vxlan(entries):
|
||||
await self._commit_vxlan(entries)
|
||||
else:
|
||||
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 _commit_vxlan(self, entries):
|
||||
"""
|
||||
Commit a cross-compute VXLAN link, retrying with a bumped VNI salt
|
||||
on failure and degrading the link to the UDP relay when the retries
|
||||
run out.
|
||||
|
||||
The kernel refuses a VNI already registered on the shared dstport
|
||||
socket at device creation (EEXIST) — the one conflict a freshly
|
||||
created, deterministically named device can hit: the other end's
|
||||
device lives on another host, and a leftover under this end's name
|
||||
is replaced by the attach path, not refused. Any other commit
|
||||
failure (a stopped node, a missing underlay route) is retried too,
|
||||
harmlessly — it fails the same way every attempt, and the final
|
||||
relay attempt surfaces the real error if the link genuinely cannot
|
||||
be created. VXLAN is opt-in and the relay always works, so a tunnel
|
||||
that cannot be wired degrades instead of failing the link; the
|
||||
degradation lasts for this link object's lifetime (a project reopen
|
||||
retries VXLAN from a clean state).
|
||||
"""
|
||||
|
||||
last_error = None
|
||||
for attempt in range(_VXLAN_ATTEMPTS):
|
||||
if attempt:
|
||||
self._vxlan_salt += 1
|
||||
entries = await self._prepare()
|
||||
if not self._has_vxlan(entries):
|
||||
# Eligibility changed under us (a capability disappeared):
|
||||
# commit the relay entries as prepared.
|
||||
await self._commit_nios(entries)
|
||||
return
|
||||
try:
|
||||
await self._commit_nios(entries)
|
||||
return
|
||||
except (ComputeError, ControllerError) as e:
|
||||
last_error = e
|
||||
log.warning(
|
||||
"Link %s: VXLAN wiring failed (%s) — falling back to the uBridge relay datapath", self._id, last_error
|
||||
)
|
||||
self._vxlan_downgraded = True
|
||||
entries = await self._prepare()
|
||||
await self._commit_nios(entries)
|
||||
|
||||
async def update(self):
|
||||
"""
|
||||
Update the link on the nodes
|
||||
|
||||
@@ -45,6 +45,23 @@ class Image(BaseTable):
|
||||
last_error = Column(String)
|
||||
templates = relationship("Template", secondary=image_template_map, back_populates="images")
|
||||
|
||||
@property
|
||||
def reference(self):
|
||||
"""An emulator-relative reference, or an absolute additional-root path."""
|
||||
import os
|
||||
|
||||
from gns3server.utils import force_unix_path
|
||||
from gns3server.utils.images import default_images_directory
|
||||
|
||||
root = os.path.abspath(default_images_directory(self.image_type))
|
||||
path = os.path.abspath(self.path)
|
||||
try:
|
||||
if os.path.commonpath([root, path]) == root:
|
||||
return force_unix_path(os.path.relpath(path, root))
|
||||
except ValueError: # Different drives on Windows.
|
||||
pass
|
||||
return force_unix_path(path)
|
||||
|
||||
|
||||
class ImageSyncJob(BaseTable):
|
||||
__tablename__ = "image_sync_jobs"
|
||||
|
||||
@@ -201,6 +201,9 @@ class QemuTemplate(Template):
|
||||
hdd_disk_image = Column(String)
|
||||
hdd_disk_interface = Column(String)
|
||||
cdrom_image = Column(String)
|
||||
cdrom2_image = Column(String)
|
||||
cdrom_interface = Column(String, nullable=False, default="ide", server_default="ide")
|
||||
cdrom2_interface = Column(String, nullable=False, default="ide", server_default="ide")
|
||||
initrd = Column(String)
|
||||
kernel_image = Column(String)
|
||||
bios_image = Column(String)
|
||||
|
||||
@@ -140,12 +140,20 @@ async def connect_to_db(app: FastAPI) -> None:
|
||||
def upgrade_inventory(connection):
|
||||
from alembic.operations import Operations
|
||||
|
||||
from gns3server.db_migrations.versions.a6c8e1b2d490_add_second_qemu_cdrom import (
|
||||
upgrade as upgrade_cdrom,
|
||||
)
|
||||
from gns3server.db_migrations.versions.d9e8a2b7c401_image_inventory_reconciliation import (
|
||||
upgrade,
|
||||
)
|
||||
from gns3server.db_migrations.versions.e2a7c9d4b601_add_cdrom_interfaces import (
|
||||
upgrade as upgrade_cdrom_interfaces,
|
||||
)
|
||||
|
||||
with Operations.context(MigrationContext.configure(connection)):
|
||||
upgrade()
|
||||
upgrade_cdrom()
|
||||
upgrade_cdrom_interfaces()
|
||||
|
||||
await conn.run_sync(upgrade_inventory)
|
||||
await conn.run_sync(run_stamp, alembic_cfg)
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
"""Add a second QEMU CD-ROM image.
|
||||
|
||||
Revision ID: a6c8e1b2d490
|
||||
Revises: f7d3a9c2e5b1
|
||||
"""
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision = "a6c8e1b2d490"
|
||||
down_revision = "f7d3a9c2e5b1"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
existing = {column["name"] for column in sa.inspect(op.get_bind()).get_columns("qemu_templates")}
|
||||
if "cdrom2_image" not in existing:
|
||||
op.add_column("qemu_templates", sa.Column("cdrom2_image", sa.String()))
|
||||
|
||||
|
||||
def downgrade():
|
||||
op.drop_column("qemu_templates", "cdrom2_image")
|
||||
@@ -0,0 +1,22 @@
|
||||
"""Add QEMU CD/DVD interfaces."""
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision = "e2a7c9d4b601"
|
||||
down_revision = "a6c8e1b2d490"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
existing = {column["name"] for column in sa.inspect(op.get_bind()).get_columns("qemu_templates")}
|
||||
for name in ("cdrom_interface", "cdrom2_interface"):
|
||||
if name in existing:
|
||||
continue
|
||||
op.add_column("qemu_templates", sa.Column(name, sa.String(), nullable=False, server_default="ide"))
|
||||
|
||||
|
||||
def downgrade():
|
||||
op.drop_column("qemu_templates", "cdrom2_interface")
|
||||
op.drop_column("qemu_templates", "cdrom_interface")
|
||||
@@ -14,15 +14,18 @@
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
from enum import StrEnum
|
||||
import re
|
||||
from enum import Enum
|
||||
from typing import List, Optional, Union
|
||||
from uuid import UUID
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
from pydantic import BaseModel, Field, model_validator
|
||||
|
||||
from ..common import NodeStatus
|
||||
from ..update import PartialUpdateModel
|
||||
|
||||
LINUX_TAP_NAME_PATTERN = r"^[a-zA-Z0-9_.:-]{1,15}$"
|
||||
|
||||
|
||||
class HostInterfaceType(StrEnum):
|
||||
ethernet = "ethernet"
|
||||
@@ -57,6 +60,7 @@ class HostInterface(BaseModel):
|
||||
name: str = Field(..., description="Interface name")
|
||||
type: HostInterfaceType = Field(..., description="Interface type")
|
||||
special: bool = Field(..., description="Whether the interface is non standard")
|
||||
is_bridge: bool = Field(False, description="Whether this is a Linux bridge")
|
||||
ip_addresses: List[HostInterfaceIPAddress] = Field(
|
||||
default_factory=list, description="All IPv4 and IPv6 addresses on this interface"
|
||||
)
|
||||
@@ -94,6 +98,19 @@ class TAPPort(BaseModel):
|
||||
port_number: int
|
||||
type: TAPType
|
||||
interface: str
|
||||
bridge: Optional[str] = Field(
|
||||
None,
|
||||
min_length=1,
|
||||
max_length=15,
|
||||
pattern=LINUX_TAP_NAME_PATTERN,
|
||||
description="Existing Linux bridge to attach the TAP to",
|
||||
)
|
||||
|
||||
@model_validator(mode="after")
|
||||
def validate_bridged_interface(self):
|
||||
if self.bridge and not re.fullmatch(LINUX_TAP_NAME_PATTERN, self.interface):
|
||||
raise ValueError("A bridged TAP name must be 1-15 letters, digits, underscores, dots, colons or hyphens")
|
||||
return self
|
||||
|
||||
|
||||
class UDPType(StrEnum):
|
||||
|
||||
@@ -73,6 +73,26 @@ class BridgeNIOType(StrEnum):
|
||||
bridge = "nio_bridge"
|
||||
|
||||
|
||||
class BridgeVxlan(BaseModel):
|
||||
"""
|
||||
VXLAN tunnel spec carried by a cross-compute BridgeNIO. Each endpoint
|
||||
creates one VXLAN device (uBridge ``vxlan create``) and enslaves it into
|
||||
the link's per-link kernel bridge next to the node's anchor; the kernel
|
||||
encapsulates/decapsulates the frames. The controller derives the name
|
||||
and VNI (utils.vxlan) and resolves the peer address, so both endpoints
|
||||
agree on the VNI without ever talking to each other.
|
||||
|
||||
The device's MTU is derived by the kernel from the underlay interface
|
||||
the route to ``remote`` picks (MTU - 50 for the outer headers), which is
|
||||
what the link's bridge then reports as "what this fabric can carry".
|
||||
"""
|
||||
|
||||
name: str = Field(..., max_length=15, description="VXLAN device name on this endpoint's host (IFNAMSIZ-1)")
|
||||
vni: int = Field(..., ge=1, le=16777215, description="VXLAN network identifier, identical on both endpoints")
|
||||
remote: str = Field(..., description="Peer compute's address (unicast tunnel endpoint)")
|
||||
dstport: int = Field(..., ge=1, le=65535, description="UDP destination port, identical on both endpoints")
|
||||
|
||||
|
||||
class BridgeNIO(BaseModel):
|
||||
"""
|
||||
Kernel-datapath bridge Network Input/Output properties. The NIO tells the
|
||||
@@ -99,6 +119,11 @@ class BridgeNIO(BaseModel):
|
||||
description="Kernel bridge name e.g. gns3a1b2c3d4e5; None when an Ethernet switch "
|
||||
"owns the anchor's bridge membership (absorbed link)",
|
||||
)
|
||||
vxlan: Optional[BridgeVxlan] = Field(
|
||||
None,
|
||||
description="Cross-compute tunnel: the VXLAN device this end creates and enslaves into the bridge "
|
||||
"next to the anchor; absent for same-host kernel links",
|
||||
)
|
||||
suspend: Optional[bool] = Field(None, description="Suspend the NIO")
|
||||
filters: Optional[dict] = Field(
|
||||
None,
|
||||
|
||||
@@ -14,8 +14,8 @@
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
from enum import StrEnum
|
||||
from typing import List, Optional
|
||||
from enum import Enum
|
||||
from typing import List, Literal, Optional
|
||||
from uuid import UUID
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
@@ -152,6 +152,9 @@ class QemuDiskInterfaceType(StrEnum):
|
||||
none = "none"
|
||||
|
||||
|
||||
QemuCdromInterface = Literal["ide", "sata"]
|
||||
|
||||
|
||||
class QemuBase(BaseModel):
|
||||
"""
|
||||
Common Qemu node properties.
|
||||
@@ -183,8 +186,12 @@ class QemuBase(BaseModel):
|
||||
hdd_disk_image_backing_file: Optional[str] = Field(None, description="QEMU hdd backing file disk image path")
|
||||
hdd_disk_image_md5sum: Optional[str] = Field(None, description="QEMU hdd disk image checksum")
|
||||
hdd_disk_interface: Optional[QemuDiskInterfaceType] = Field(None, description="QEMU hdd interface")
|
||||
cdrom_image: Optional[str] = Field(None, description="QEMU cdrom image path")
|
||||
cdrom_image_md5sum: Optional[str] = Field(None, description="QEMU cdrom image checksum")
|
||||
cdrom_image: Optional[str] = Field(None, description="QEMU first cdrom image path")
|
||||
cdrom_interface: QemuCdromInterface = Field("ide", description="First CD/DVD interface")
|
||||
cdrom2_interface: QemuCdromInterface = Field("ide", description="Second CD/DVD interface")
|
||||
cdrom2_image: Optional[str] = Field(None, description="QEMU second cdrom image path")
|
||||
cdrom_image_md5sum: Optional[str] = Field(None, description="QEMU first cdrom image checksum")
|
||||
cdrom2_image_md5sum: Optional[str] = Field(None, description="QEMU second cdrom image checksum")
|
||||
bios_image: Optional[str] = Field(None, description="QEMU bios image path")
|
||||
bios_image_md5sum: Optional[str] = Field(None, description="QEMU bios image checksum")
|
||||
initrd: Optional[str] = Field(None, description="QEMU initrd path")
|
||||
|
||||
@@ -192,6 +192,23 @@ class ServerSettings(BaseModel):
|
||||
"or QEMU when the compute's uBridge supports persistent TAPs) through kernel interfaces instead "
|
||||
"of the uBridge UDP relay",
|
||||
)
|
||||
enable_vxlan_cross_compute: bool = Field(
|
||||
False,
|
||||
description="Wire eligible cross-compute links through kernel VXLAN tunnels (one device per endpoint, "
|
||||
"uBridge's vxlan module) instead of the uBridge UDP relay. The kernel encapsulates/decapsulates, so "
|
||||
"no user-space forwarding is involved; the price is MTU: a tunnel can only carry the underlay MTU "
|
||||
"minus 50 bytes of outer headers, so enable this where the fabric between computes carries jumbo "
|
||||
"frames. The relay stays the default and the fallback when a tunnel cannot be wired",
|
||||
)
|
||||
vxlan_dstport: int = Field(
|
||||
4789,
|
||||
ge=1,
|
||||
le=65535,
|
||||
description="UDP destination port every cross-compute VXLAN tunnel listens on. The kernel shares one "
|
||||
"socket per port across all VXLAN devices, so all links of a deployment ride this one port — the "
|
||||
"IANA-assigned VXLAN port by default. Change it when other VXLAN software on the computes already "
|
||||
"listens on it: a shared socket merges their demux table with ours",
|
||||
)
|
||||
auto_discover_images: bool = Field(True, description="Automatically discover images in the images directory")
|
||||
image_sync_interval: int = Field(900, ge=10, description="Seconds between automatic image inventory scans")
|
||||
report_errors: bool = Field(True, description="Automatically send crash reports to the GNS3 team")
|
||||
|
||||
@@ -23,6 +23,7 @@ from uuid import UUID
|
||||
from pydantic import AnyUrl, BaseModel, BeforeValidator, Discriminator, EmailStr, Field, Tag
|
||||
|
||||
from ..common import ExtraConfig
|
||||
from ..compute.qemu_nodes import QemuCdromInterface
|
||||
|
||||
# ============================================================================
|
||||
# Shared Enums
|
||||
@@ -398,6 +399,8 @@ class Qemu(BaseModel):
|
||||
hdc_disk_interface: Optional[QemuDiskInterface] = Field(
|
||||
None, title="Disk interface for the installed hdc_disk_image"
|
||||
)
|
||||
cdrom_interface: Optional[QemuCdromInterface] = Field(None, title="First CD/DVD interface")
|
||||
cdrom2_interface: Optional[QemuCdromInterface] = Field(None, title="Second CD/DVD interface")
|
||||
hdd_disk_interface: Optional[QemuDiskInterface] = Field(
|
||||
None, title="Disk interface for the installed hdd_disk_image"
|
||||
)
|
||||
@@ -425,7 +428,8 @@ class ApplianceVersionImages(BaseModel):
|
||||
hdb_disk_image: Optional[str] = Field(None, title="Hdc disk image")
|
||||
hdc_disk_image: Optional[str] = Field(None, title="Hdd disk image")
|
||||
hdd_disk_image: Optional[str] = Field(None, title="Hdd diskimage")
|
||||
cdrom_image: Optional[str] = Field(None, title="cdrom image")
|
||||
cdrom_image: Optional[str] = Field(None, title="First cdrom image")
|
||||
cdrom2_image: Optional[str] = Field(None, title="Second cdrom image")
|
||||
|
||||
|
||||
class ApplianceVersion(BaseModel):
|
||||
@@ -578,6 +582,8 @@ class QemuPropertiesV8(BaseModel):
|
||||
hdc_disk_interface: Optional[QemuDiskInterface] = Field(
|
||||
None, title="Disk interface for the installed hdc_disk_image"
|
||||
)
|
||||
cdrom_interface: Optional[QemuCdromInterface] = Field(None, title="First CD/DVD interface")
|
||||
cdrom2_interface: Optional[QemuCdromInterface] = Field(None, title="Second CD/DVD interface")
|
||||
hdd_disk_interface: Optional[QemuDiskInterface] = Field(
|
||||
None, title="Disk interface for the installed hdd_disk_image"
|
||||
)
|
||||
|
||||
@@ -73,3 +73,8 @@ class Capabilities(BaseModel):
|
||||
"keeps the guest from auto-configuring the host side of the anchor TAP); absent when the "
|
||||
"probe failed",
|
||||
)
|
||||
ubridge_vxlan: Optional[bool] = Field(
|
||||
None,
|
||||
description="Whether uBridge can create VXLAN devices (the vxlan module, kernel support included) — "
|
||||
"the tunnel a cross-compute kernel link rides; absent when the probe failed",
|
||||
)
|
||||
@@ -37,6 +37,9 @@ class ImageBase(BaseModel):
|
||||
|
||||
filename: str = Field(..., description="Image filename")
|
||||
path: str = Field(..., description="Image path")
|
||||
reference: Optional[str] = Field(
|
||||
None, description="Image reference relative to its emulator directory, or an absolute additional-root path"
|
||||
)
|
||||
image_type: ImageType = Field(..., description="Image type")
|
||||
image_size: int = Field(..., description="Image size in bytes")
|
||||
checksum: str = Field(..., description="Checksum value")
|
||||
|
||||
@@ -29,6 +29,9 @@ class ReplaySource(BaseModel):
|
||||
marker: str
|
||||
data_link_type: str
|
||||
count: int = Field(..., description="Total frames in the capture, unaffected by filters")
|
||||
error: Optional[str] = Field(
|
||||
None, description="Set when the capture file could not be fetched from the compute hosting it"
|
||||
)
|
||||
|
||||
|
||||
class ReplayFrame(BaseModel):
|
||||
|
||||
@@ -23,6 +23,7 @@ from gns3server.schemas.compute.qemu_nodes import (
|
||||
CustomAdapter,
|
||||
QemuAdapterType,
|
||||
QemuBootPriority,
|
||||
QemuCdromInterface,
|
||||
QemuConsoleType,
|
||||
QemuDiskInterfaceType,
|
||||
QemuOnCloseAction,
|
||||
@@ -79,7 +80,10 @@ class QemuTemplate(TemplateBase):
|
||||
hdd_disk_interface: Optional[QemuDiskInterfaceType] = Field(
|
||||
QemuDiskInterfaceType.none, description="QEMU hdd interface"
|
||||
)
|
||||
cdrom_image: Optional[str] = Field("", description="QEMU cdrom image path")
|
||||
cdrom_image: Optional[str] = Field("", description="QEMU first cdrom image path")
|
||||
cdrom_interface: QemuCdromInterface = Field("ide", description="First CD/DVD interface")
|
||||
cdrom2_interface: QemuCdromInterface = Field("ide", description="Second CD/DVD interface")
|
||||
cdrom2_image: Optional[str] = Field("", description="QEMU second cdrom image path")
|
||||
initrd: Optional[str] = Field("", description="QEMU initrd path")
|
||||
kernel_image: Optional[str] = Field("", description="QEMU kernel image path")
|
||||
bios_image: Optional[str] = Field("", description="QEMU bios image path")
|
||||
|
||||
@@ -49,6 +49,9 @@ from . import force_unix_path
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
# Include the first ISO 9660 volume descriptor in image type detection.
|
||||
IMAGE_HEADER_SIZE = 16 * 2048 + 7
|
||||
|
||||
|
||||
async def list_images(image_type):
|
||||
"""
|
||||
@@ -145,7 +148,7 @@ def inspect_image_file(path, expected_image_type=None, allow_raw_image=False, st
|
||||
info = os.fstat(f.fileno())
|
||||
if not stat.S_ISREG(info.st_mode) or stat_fingerprint(info) != before:
|
||||
raise ImageChangedError(f"Image changed while opening: {path}")
|
||||
header = f.read(7)
|
||||
header = f.read(IMAGE_HEADER_SIZE)
|
||||
if len(header) < 7:
|
||||
raise InvalidImageError(f"Image '{path}' is too small to be valid")
|
||||
image_type = check_valid_image_header(path, header, allow_raw_image)
|
||||
@@ -359,6 +362,8 @@ def check_valid_image_header(path: str, data: bytes, allow_raw_image: bool = Fal
|
||||
elif data[:4] == b"QFI\xfb" or data[:4] == b"KDMV":
|
||||
# for Qemy images: file must be QCOW2 or VMDK
|
||||
return "qemu"
|
||||
elif data[16 * 2048 + 1 : 16 * 2048 + 6] == b"CD001":
|
||||
return "qemu"
|
||||
else:
|
||||
if allow_raw_image is True:
|
||||
return "qemu"
|
||||
@@ -380,12 +385,14 @@ async def write_image(
|
||||
if subdirectory is not None:
|
||||
validate_image_upload_name(image_filename)
|
||||
subfolders = validate_image_subdirectory(subdirectory)
|
||||
# HTTP chunk boundaries need not align with the seven-byte image header.
|
||||
# HTTP chunk boundaries need not align with the image header or ISO descriptor.
|
||||
iterator = stream.__aiter__()
|
||||
prefix = bytearray()
|
||||
async for chunk in iterator:
|
||||
prefix.extend(chunk)
|
||||
if len(prefix) >= 7:
|
||||
# Known disk formats keep their original early detection behavior.
|
||||
header_size = 7 if prefix.startswith((b"\x7fELF", b"QFI\xfb", b"KDMV")) else IMAGE_HEADER_SIZE
|
||||
if len(prefix) >= header_size:
|
||||
break
|
||||
if len(prefix) < 7:
|
||||
raise InvalidImageError("The image content is empty or too small to be valid")
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
#
|
||||
# Copyright (C) 2026 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/>.
|
||||
|
||||
"""
|
||||
Identity of a cross-compute VXLAN link: the device name and VNI the
|
||||
controller derives and ships in both endpoints' NIOs.
|
||||
|
||||
A cross-compute kernel link is carried by one VXLAN device per endpoint
|
||||
(uBridge's ``vxlan`` module, rtnetlink), enslaved into the link's per-link
|
||||
kernel bridge next to the node's anchor. Each device is local to its own
|
||||
host, but the VNI is the kernel's demux key for received frames on the
|
||||
deployment-wide shared dstport socket — both ends must agree on it while
|
||||
neither can ask the other, so the controller is the single source for both
|
||||
values.
|
||||
|
||||
* the device name is a pure function of the link id plus the endpoint's
|
||||
node id: unique per (link, endpoint), within IFNAMSIZ, and outside the
|
||||
``gns3``/anchor name spaces (``gvl`` prefix). Determinism is what makes
|
||||
a crashed run's leftover device recognisable — the compute deletes a
|
||||
device it finds under the name before re-creating it;
|
||||
* the VNI is a pure function of the link id and a controller-side salt —
|
||||
a random base per link object, bumped when a retry needs a fresh VNI —
|
||||
drawn from the upper half of the VNI space so it cannot collide with the
|
||||
low well-known VNIs other software uses (Flannel's 1, Docker overlay
|
||||
networks from 256, ...).
|
||||
|
||||
The kernel refuses a second device registering a VNI already taken on the
|
||||
same (dstport, socket) pair with EEXIST — a check that doubles as the
|
||||
collision detector: the controller retries with a bumped salt and falls
|
||||
back to the UDP relay when the space stays occupied (the relay needs no
|
||||
VNI at all).
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
|
||||
# The VNI range derived links draw from: at or above 0x100000 (1M) to stay
|
||||
# clear of the low VNIs other VXLAN software commonly registers, at or below
|
||||
# 0xFFFFFE so every derived value is valid for the module's 1..16777215
|
||||
# (0xFFFFFF) check with one value of headroom.
|
||||
VXLAN_VNI_MIN = 0x100000
|
||||
VXLAN_VNI_MAX = 0xFFFFFE
|
||||
|
||||
# IFNAMSIZ (16) - 1 for the terminator: "gvl" (3) + 8 (link) + 4 (node) = 15.
|
||||
VXLAN_NAME_PREFIX = "gvl"
|
||||
|
||||
|
||||
def derive_vni(link_id, salt=0):
|
||||
"""
|
||||
The VNI both endpoints of *link_id* tunnel through.
|
||||
|
||||
:param link_id: link UUID (or str)
|
||||
:param salt: controller-side retry counter — bumped when the kernel
|
||||
refuses the previous VNI as already registered on the shared
|
||||
dstport socket; 0 for the first attempt
|
||||
:returns: VNI in [VXLAN_VNI_MIN, VXLAN_VNI_MAX]
|
||||
"""
|
||||
|
||||
digest = hashlib.sha256(f"{link_id}:{salt}".encode()).digest()
|
||||
span = VXLAN_VNI_MAX - VXLAN_VNI_MIN + 1
|
||||
return VXLAN_VNI_MIN + int.from_bytes(digest[:8], "big") % span
|
||||
|
||||
|
||||
def vxlan_device_name(link_id, node_id):
|
||||
"""
|
||||
The VXLAN device name one endpoint of *link_id* creates on its own host,
|
||||
deterministic from the link id plus the endpoint's node id (the pair
|
||||
normally lives on two hosts, but a forced same-host pair must not name
|
||||
both its devices identically).
|
||||
"""
|
||||
|
||||
link = str(link_id).replace("-", "")[:8]
|
||||
node = str(node_id).replace("-", "")[:4]
|
||||
return f"{VXLAN_NAME_PREFIX}{link}{node}"
|
||||
Reference in new issue
Block a user