From 37d7b9121e93c6a0011fd4a9525d8a0f56dc5702 Mon Sep 17 00:00:00 2001 From: YueGuobin Date: Mon, 28 Sep 2026 00:21:58 +0800 Subject: [PATCH] feat(docker): wire Docker-to-Docker links through a kernel bridge Eligible links (both endpoints Docker, same compute, no filters/markers, stopped nodes, no unix-socket NIO containers) are wired as veth pairs enslaved into a per-link Linux kernel bridge (gns3 + 11 hex of the link id, filling IFNAMSIZ) instead of the uBridge UDP relay, so frames flow entirely in the kernel. - NIOBridge NIO type: the controller decides the datapath in UDPLink._prepare and both computes enslave their deterministic gv*/gc* veth pair via uBridge's docker/brctl modules - carrier/suspend = host-end admin state; both endpoints race-safely create/delete the bridge (EEXIST/EBUSY/ENOENT tolerated) - veth adapters can still attach relay (UDP) links on a running node: lazy relay bridge over AF_PACKET on the host end (add_nio_ethernet) - filters/markers/capture are rejected with explicit errors on this datapath (they live in the uBridge relay); vendor unix-socket containers are excluded; Server.enable_kernel_datapath is the kill-switch (default on) - host-end veths survive container death and are removed on stop plus swept by name on the next start (crash/stale recovery) --- gns3server/api/routes/compute/docker_nodes.py | 14 +- gns3server/compute/base_manager.py | 6 + gns3server/compute/docker/docker_vm.py | 227 ++++++++++- gns3server/compute/docker/vendor_docker_vm.py | 7 + gns3server/compute/nios/nio_bridge.py | 67 ++++ gns3server/controller/link.py | 15 + gns3server/controller/udp_link.py | 68 ++++ gns3server/schemas/__init__.py | 3 +- gns3server/schemas/compute/nios.py | 25 +- gns3server/schemas/config.py | 5 + .../docker/test_docker_kernel_datapath.py | 373 ++++++++++++++++++ tests/compute/docker/test_vendor_docker_vm.py | 15 + tests/controller/test_kernel_datapath_link.py | 227 +++++++++++ 13 files changed, 1031 insertions(+), 21 deletions(-) create mode 100644 gns3server/compute/nios/nio_bridge.py create mode 100644 tests/compute/docker/test_docker_kernel_datapath.py create mode 100644 tests/controller/test_kernel_datapath_link.py diff --git a/gns3server/api/routes/compute/docker_nodes.py b/gns3server/api/routes/compute/docker_nodes.py index 37c00d5a3..5e7f1af98 100644 --- a/gns3server/api/routes/compute/docker_nodes.py +++ b/gns3server/api/routes/compute/docker_nodes.py @@ -260,12 +260,13 @@ async def duplicate_docker_node( @router.post( "/{node_id}/adapters/{adapter_number}/ports/{port_number}/nio", status_code=status.HTTP_201_CREATED, - response_model=schemas.UDPNIO, + response_model=Union[schemas.UDPNIO, schemas.BridgeNIO], dependencies=[Depends(compute_authentication)], ) async def create_docker_node_nio( - adapter_number: int, port_number: int, nio_data: schemas.UDPNIO, node: DockerVM = Depends(dep_node) -) -> schemas.UDPNIO: + adapter_number: int, port_number: int, nio_data: Union[schemas.UDPNIO, schemas.BridgeNIO], + node: DockerVM = Depends(dep_node) +) -> Union[schemas.UDPNIO, schemas.BridgeNIO]: """ Add a NIO (Network Input/Output) to the node. The port number on the Docker node is always 0. @@ -279,12 +280,13 @@ async def create_docker_node_nio( @router.put( "/{node_id}/adapters/{adapter_number}/ports/{port_number}/nio", status_code=status.HTTP_201_CREATED, - response_model=schemas.UDPNIO, + response_model=Union[schemas.UDPNIO, schemas.BridgeNIO], dependencies=[Depends(compute_authentication)], ) async def update_docker_node_nio( - adapter_number: int, port_number: int, nio_data: schemas.UDPNIO, node: DockerVM = Depends(dep_node) -) -> schemas.UDPNIO: + adapter_number: int, port_number: int, nio_data: Union[schemas.UDPNIO, schemas.BridgeNIO], + node: DockerVM = Depends(dep_node) +) -> Union[schemas.UDPNIO, schemas.BridgeNIO]: """ Update a NIO (Network Input/Output) on the node. The port number on the Docker node is always 0. diff --git a/gns3server/compute/base_manager.py b/gns3server/compute/base_manager.py index a455beb97..e7cd790fb 100644 --- a/gns3server/compute/base_manager.py +++ b/gns3server/compute/base_manager.py @@ -41,6 +41,7 @@ from ..utils import force_unix_path from ..utils.images import default_images_directory, images_directories, list_images, md5sum, remove_checksum from .base_node import BaseNode from .error import ImageMissingError, NodeError +from .nios.nio_bridge import NIOBridge from .nios.nio_ethernet import NIOEthernet from .nios.nio_tap import NIOTAP from .nios.nio_udp import NIOUDP @@ -358,6 +359,11 @@ class BaseManager: nio.filters = nio_settings.get("filters", {}) nio.markers = nio_settings.get("markers", {}) nio.suspend = nio_settings.get("suspend", False) + elif nio_settings["type"] == "nio_bridge": + nio = NIOBridge(nio_settings["bridge"]) + if nio_settings.get("filters") or nio_settings.get("markers"): + raise ComputeError("Packet filters and markers are not supported on kernel-datapath links") + nio.suspend = nio_settings.get("suspend", False) elif nio_settings["type"] == "nio_tap": tap_device = nio_settings["tap_device"] # if not is_interface_up(tap_device): diff --git a/gns3server/compute/docker/docker_vm.py b/gns3server/compute/docker/docker_vm.py index 97cc661a6..c9dcf6cc5 100644 --- a/gns3server/compute/docker/docker_vm.py +++ b/gns3server/compute/docker/docker_vm.py @@ -44,6 +44,7 @@ from gns3server.utils.hostname import is_rfc1123_hostname_valid from ..adapters.ethernet_adapter import EthernetAdapter from ..base_node import BaseNode from ..error import ImageMissingError +from ..nios.nio_bridge import NIOBridge from ..nios.nio_udp import NIOUDP from .docker_error import DockerError, DockerHttp404Error, DockerHttp409Error @@ -169,6 +170,10 @@ class DockerVM(BaseNode): self._volumes = [] # Keep a list of created bridge self._bridges = set() + # (adapter_number, port_number) -> veth host-end name for adapters + # wired on the kernel datapath (NIOBridge). The host end lives in the + # root namespace and survives container death, unlike relay TAPs. + self._kernel_veths = {} if adapters is None: self.adapters = 1 @@ -1422,6 +1427,9 @@ class DockerVM(BaseNode): self._console_websocket = None self._cleanup_console_resources() await self._clean_servers() + # veth host ends live in the root namespace and survive container + # death — remove them while the uBridge control channel is still up. + await self._remove_kernel_veths() await self._stop_ubridge() try: @@ -1745,6 +1753,10 @@ class DockerVM(BaseNode): f"Port {port_number} doesn't exist on adapter {adapter_number} of Docker container '{self.name}'" ) + if isinstance(nio, NIOBridge): + await self._add_ubridge_kernel_connection(nio, adapter_number, port_number) + return + for index in range(4096): if f"tap-gns3-e{index}" not in psutil.net_if_addrs(): adapter.host_ifc = f"tap-gns3-e{index!s}" @@ -1758,11 +1770,7 @@ class DockerVM(BaseNode): self._bridges.add(bridge_name) await self._ubridge_send(f"bridge add_nio_tap {bridge_name} {adapter.host_ifc} off") - mac_address = int_to_macaddress(macaddress_to_int(self._mac_address) + adapter_number) - custom_adapter = self._get_custom_adapter_settings(adapter_number) - custom_mac_address = custom_adapter.get("mac_address") - if custom_mac_address: - mac_address = custom_mac_address + mac_address = self._adapter_mac_address(adapter_number) try: await self._ubridge_send(f"docker set_mac_addr {adapter.host_ifc} {mac_address}") @@ -1782,13 +1790,131 @@ class DockerVM(BaseNode): await self._connect_nio(adapter_number, nio, port_number) await self._set_adapter_carrier(adapter_number, not nio.suspend, port_number) + def _adapter_mac_address(self, adapter_number): + """ + MAC address for an adapter: the node base address plus the adapter + number, unless the adapter has a custom MAC configured. + """ + + mac_address = int_to_macaddress(macaddress_to_int(self._mac_address) + adapter_number) + custom_adapter = self._get_custom_adapter_settings(adapter_number) + custom_mac_address = custom_adapter.get("mac_address") + if custom_mac_address: + mac_address = custom_mac_address + return mac_address + + def _veth_names(self, adapter_number, port_number=0): + """ + Deterministic veth pair names for a kernel-datapath adapter port. + The host end stays in the root namespace (enslaved to the per-link + kernel bridge); the guest end is moved into the container namespace + and renamed. The ``gv``/``gc`` prefixes keep these out of the ``gns3`` + bridge/TAP name space and serve as search keys for stale-interface + cleanup. 8 hex chars of the node id + adapter/port keep the names + unique and within IFNAMSIZ (15). + """ + + suffix = f"{self._id.replace('-', '')[:8]}e{adapter_number}p{port_number}" + return f"gv{suffix}", f"gc{suffix}" + + async def _add_ubridge_kernel_connection(self, nio, adapter_number, port_number=0): + """ + Wire an adapter port on the kernel datapath (NIOBridge): a veth pair + whose host end will be enslaved to the per-link kernel bridge, and + whose guest end becomes the container interface (the role the TAP + plays on the relay path). No uBridge relay bridge is created — frames + flow entirely in the kernel, which is also why filters, markers and + capture are unavailable on this datapath. + """ + + try: + adapter = self._ethernet_adapters[adapter_number] + except IndexError: + raise DockerError( + "Adapter {adapter_number} doesn't exist on Docker container '{name}'".format( + name=self.name, adapter_number=adapter_number + ) + ) + + host_ifc, guest_ifc = self._veth_names(adapter_number, port_number) + # The host end lives in the root namespace and survives container + # death: best-effort removal of a stale pair from a previous run + # (unclean stop or crash) before recreating it. + for stale_ifc in (host_ifc, guest_ifc): + with contextlib.suppress(UbridgeError): + await self._ubridge_send(f'docker delete_veth "{stale_ifc}"') + + mac_address = self._adapter_mac_address(adapter_number) + try: + await self._ubridge_send(f'docker create_veth "{host_ifc}" "{guest_ifc}"') + # create_veth leaves the host end UP; carrier must be off until + # a link attaches (mirrors "bridge add_nio_tap ... off"). + await self._ubridge_send(f'link set "{host_ifc}" down') + try: + await self._ubridge_send(f"docker set_mac_addr {guest_ifc} {mac_address}") + except UbridgeError: + log.warning(f"Could not set MAC address {mac_address} on interface {guest_ifc}") + + ifname = self._get_container_ifname(adapter_number) + log.debug(f"Move container {self.name} adapter {guest_ifc} -> {ifname} in ns {self._namespace}") + try: + await self._ubridge_send(f"docker move_to_ns {guest_ifc} {self._namespace} {ifname}") + except UbridgeError as e: + raise UbridgeNamespaceError(e) + except UbridgeNamespaceError: + raise + except Exception: + # Don't leave a half-created pair behind (the next start would + # fail on create_veth EEXIST until the stale sweep runs). + with contextlib.suppress(UbridgeError): + await self._ubridge_send(f'docker delete_veth "{host_ifc}"') + raise + + adapter.host_ifc = host_ifc + self._kernel_veths[(adapter_number, port_number)] = host_ifc + log.debug( + "Created kernel-datapath adapter {adapter_number} port {port_number} with MAC address {mac_address} in namespace {namespace}".format( + adapter_number=adapter_number, port_number=port_number, + mac_address=mac_address, namespace=self._namespace, + ) + ) + + if nio: + await self._connect_nio(adapter_number, nio, port_number) + await self._set_adapter_carrier(adapter_number, not nio.suspend, port_number) + + async def _remove_kernel_veths(self): + """ + Delete the root-namespace veth host ends. Unlike relay TAPs (which die + with the container network namespace) the host end outlives the + container and must be removed explicitly. Deleting an enslaved veth + detaches it from its bridge automatically. + """ + + if self.ubridge: + for host_ifc in self._kernel_veths.values(): + with contextlib.suppress(UbridgeError): + await self._ubridge_send(f'docker delete_veth "{host_ifc}"') + self._kernel_veths.clear() + async def _get_namespace(self): result = await self.manager.query("GET", f"containers/{self._cid}/json") return int(result["State"]["Pid"]) async def _set_adapter_carrier(self, adapter_number, connected, port_number=0): - """Replicate a Docker adapter's connection state on its TAP device.""" + """Replicate a Docker adapter's connection state on its host interface. + + Kernel-datapath adapters toggle the veth host end's admin state (the + container end loses carrier when its peer is down); relay adapters + toggle the TAP carrier from the uBridge fd holder. + """ + + host_ifc = self._kernel_veths.get((adapter_number, port_number)) + if host_ifc is not None: + state = "up" if connected else "down" + await self._ubridge_send(f'link set "{host_ifc}" {state}') + return bridge_name = self._bridge_name(adapter_number, port_number) state = "on" if connected else "off" @@ -1796,8 +1922,45 @@ class DockerVM(BaseNode): async def _connect_nio(self, adapter_number, nio, port_number=0): + if isinstance(nio, NIOBridge): + host_ifc = self._kernel_veths.get((adapter_number, port_number)) + if host_ifc is None: + raise DockerError( + "Adapter {adapter_number} port {port_number} of container '{name}' has no " + "kernel-datapath interface (it was started on the relay datapath); " + "restart the node to attach a kernel link to it".format( + adapter_number=adapter_number, port_number=port_number, name=self.name + ) + ) + # Both endpoints run this concurrently and either may create the + # bridge first: treat an existing bridge as success instead of a + # create race failure. + try: + await self._ubridge_send(f'brctl create "{nio.bridge}"') + except UbridgeError: + # Raises again if the bridge genuinely does not exist. + 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}" "{host_ifc}"') + return + bridge_name = self._bridge_name(adapter_number, port_number) - await self._ubridge_send(f"bridge add_nio_udp {bridge_name} {nio.lport} {nio.rhost} {nio.rport}") + if (adapter_number, port_number) in self._kernel_veths and bridge_name not in self._bridges: + # A veth adapter attaching a UDP NIO (kernel link deleted and a + # relay link created on the running node): relay through AF_PACKET + # on the veth host end instead of a TAP. + await self._ubridge_send(f"bridge create {bridge_name}") + self._bridges.add(bridge_name) + await self._ubridge_send( + 'bridge add_nio_ethernet {bridge_name} "{hostif}"'.format( + bridge_name=bridge_name, hostif=self._kernel_veths[(adapter_number, port_number)] + ) + ) + await self._ubridge_send( + "bridge add_nio_udp {bridge_name} {lport} {rhost} {rport}".format( + bridge_name=bridge_name, lport=nio.lport, rhost=nio.rhost, rport=nio.rport + ) + ) if nio.capturing: await self._ubridge_send(f'bridge start_capture {bridge_name} "{nio.pcap_output_file}"') await self._ubridge_send(f"bridge start {bridge_name}") @@ -1840,6 +2003,16 @@ class DockerVM(BaseNode): """ if self.ubridge: + if isinstance(nio, NIOBridge): + if nio.filters or nio.markers: + raise DockerError( + "Packet filters and markers are not supported on kernel-datapath links; " + "delete and recreate the link to use them" + ) + if self.status == "started": + await self._set_adapter_carrier(adapter_number, not nio.suspend, port_number) + return + bridge_name = self._bridge_name(adapter_number, port_number) if bridge_name in self._bridges: await self._ubridge_apply_filters(bridge_name, nio.filters) @@ -1865,16 +2038,41 @@ class DockerVM(BaseNode): await self.stop_capture(adapter_number, port_number) if self.ubridge: nio = adapter.get_nio(port_number) - bridge_name = self._bridge_name(adapter_number, port_number) - if self.status == "started": - await self._set_adapter_carrier(adapter_number, False, port_number) - await self._ubridge_send(f"bridge stop {bridge_name}") - await self._ubridge_send(f"bridge remove_nio_udp {bridge_name} {nio.lport} {nio.rhost} {nio.rport}") + if isinstance(nio, NIOBridge): + await self._remove_kernel_nio(nio, adapter_number, port_number) + else: + bridge_name = self._bridge_name(adapter_number, port_number) + if self.status == "started": + await self._set_adapter_carrier(adapter_number, False, port_number) + await self._ubridge_send(f"bridge stop {bridge_name}") + await self._ubridge_send( + "bridge remove_nio_udp {bridge_name} {lport} {rhost} {rport}".format( + bridge_name=bridge_name, lport=nio.lport, rhost=nio.rhost, rport=nio.rport + ) + ) adapter.remove_nio(port_number) log.debug(f"Docker VM '{self.name}' [{self.id}]: {adapter.host_ifc} removed from adapter {adapter_number}") + async def _remove_kernel_nio(self, nio, adapter_number, port_number=0): + """ + Detach a kernel-datapath adapter port from its per-link kernel bridge. + Both endpoints run this concurrently: deleting a bridge that still has + the peer's port enslaved fails with EBUSY, so the last endpoint to + remove its port wins the deletion and the loser's failure is expected + and suppressed. + """ + + host_ifc = self._kernel_veths.get((adapter_number, port_number)) + if self.status == "started" and host_ifc is not None: + await self._set_adapter_carrier(adapter_number, False, port_number) + if host_ifc is not None: + with contextlib.suppress(UbridgeError): + await self._ubridge_send(f'brctl delif "{nio.bridge}" "{host_ifc}"') + with contextlib.suppress(UbridgeError): + await self._ubridge_send(f'brctl delete "{nio.bridge}"') + def get_nio(self, adapter_number, port_number=0): """ Gets an adapter NIO binding. @@ -1964,6 +2162,11 @@ class DockerVM(BaseNode): nio = self.get_nio(adapter_number, port_number) if nio.capturing: raise DockerError(f"Packet capture is already activated on adapter {adapter_number}") + if isinstance(nio, NIOBridge): + raise DockerError( + "Packet capture is not supported on kernel-datapath links (there is no uBridge " + "relay in the forwarding path); delete and recreate the link to capture it" + ) nio.start_packet_capture(output_file) if self.status == "started" and self.ubridge: diff --git a/gns3server/compute/docker/vendor_docker_vm.py b/gns3server/compute/docker/vendor_docker_vm.py index 977e3ace4..c1fac493b 100644 --- a/gns3server/compute/docker/vendor_docker_vm.py +++ b/gns3server/compute/docker/vendor_docker_vm.py @@ -36,6 +36,7 @@ import tempfile from gns3server.compute.docker.docker_error import DockerError, DockerHttp304Error, DockerHttp404Error from gns3server.compute.docker.docker_vm import DockerVM +from gns3server.compute.nios.nio_bridge import NIOBridge from gns3server.utils.asyncio import wait_for_file_creation from gns3server.utils.asyncio.telnet_server import AsyncioTelnetServer @@ -415,6 +416,12 @@ class VendorDockerVM(DockerVM): if not self._unix_socket_nio: return await super()._add_ubridge_connection(nio, adapter_number, port_number) + if isinstance(nio, NIOBridge): + raise DockerError( + f"Container '{self._name}' bridges its adapters through AF_UNIX sockets; " + "kernel-datapath links are not supported for this container" + ) + try: adapter = self._ethernet_adapters[adapter_number] except IndexError: diff --git a/gns3server/compute/nios/nio_bridge.py b/gns3server/compute/nios/nio_bridge.py new file mode 100644 index 000000000..6692752d1 --- /dev/null +++ b/gns3server/compute/nios/nio_bridge.py @@ -0,0 +1,67 @@ +# +# 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 . +# + +""" +Interface for kernel-datapath bridge NIOs. + +A NIO of this type instructs the node to enslave its adapter's veth host end +into the named Linux kernel bridge instead of wiring a uBridge UDP relay. +Frames then flow entirely in the kernel (veth -> bridge -> veth). Filters, +markers and packet capture are not available on this datapath (they live in +the uBridge relay); the controller must only emit this NIO type for links +where those features are not in use. +""" + +from .nio import NIO + + +class NIOBridge(NIO): + + """ + Kernel-bridge NIO. + + :param bridge: name of the per-link Linux kernel bridge + """ + + def __init__(self, bridge): + + super().__init__() + self._bridge = bridge + + @property + def bridge(self): + """ + Returns the kernel bridge name. + + :returns: bridge name + """ + + return self._bridge + + def __str__(self): + + return "NIO bridge" + + def asdict(self): + + return { + "type": "nio_bridge", + "bridge": self._bridge, + "suspend": self._suspended, + "filters": self._filters, + "markers": self._markers, + } diff --git a/gns3server/controller/link.py b/gns3server/controller/link.py index ff20f7c6b..1091192b8 100644 --- a/gns3server/controller/link.py +++ b/gns3server/controller/link.py @@ -111,6 +111,15 @@ class Link: """ return self._filters + @property + def kernel_datapath(self): + """ + Whether this link is wired on the kernel datapath (veth pairs + enslaved into a per-link Linux bridge — no uBridge relay in the + forwarding path, so filters, markers and capture are unavailable). + """ + return any(d.get("type") == "nio_bridge" for d in (getattr(self, "_link_data", None) or [])) + @property def markers(self): """ @@ -224,6 +233,12 @@ class Link: except FilterValidationError as e: raise ControllerError(f"Invalid packet filter parameters: {e!s}") + if new_filters and self.kernel_datapath: + raise ControllerError( + "Packet filters are not supported on kernel-datapath links (no uBridge relay " + "in the forwarding path); delete and recreate the link to use filters" + ) + if new_filters != self.filters: self._filters = new_filters if self._created: diff --git a/gns3server/controller/udp_link.py b/gns3server/controller/udp_link.py index 2763c48e7..7a479c1cd 100644 --- a/gns3server/controller/udp_link.py +++ b/gns3server/controller/udp_link.py @@ -19,6 +19,7 @@ import asyncio import logging +from gns3server.config import Config from gns3server.utils.packet_filter_validation import validate_bpf_syntax from .controller_error import ControllerError, ControllerNotFoundError @@ -46,6 +47,19 @@ _MARKER_CAPABLE_TYPES = frozenset( log = logging.getLogger(__name__) +def _is_unix_socket_docker(node): + """ + Best-effort detection of vendor containers bridged through AF_UNIX + socket pairs (GNS3_UNIX_SOCKET_NIO in their environment): they have no + host-side interface to enslave into a kernel bridge. The compute side + independently rejects kernel-datapath NIOs on these containers, so a + missed detection here only surfaces as a clearer-late error. + """ + + environment = (node.properties or {}).get("environment") or "" + return "GNS3_UNIX_SOCKET_NIO" in environment + + class UDPLink(Link): def __init__(self, project, link_id=None): super().__init__(project, link_id=link_id) @@ -110,6 +124,38 @@ class UDPLink(Link): """ return self._markers_for_node(node1), self._markers_for_node(node2) + def _kernel_datapath_eligible(self, node1, node2): + """ + Whether this link can be wired on the kernel datapath (veth pairs + enslaved into a per-link Linux bridge) instead of the uBridge UDP + relay. The kernel path has no userspace relay, so filters, markers + and packet capture — which all live in the relay — disqualify it. + It is host-local (same compute) and requires the adapter interfaces + to be created as veths at container start, hence stopped nodes only. + """ + + if not Config.instance().settings.Server.enable_kernel_datapath: + return False + if node1.node_type != "docker" or node2.node_type != "docker": + return False + if node1.compute.id != node2.compute.id: + return False + if self.get_active_filters(): + return False + if self._markers: + return False + # Project-level marker definitions are inherited by every link right + # after creation (apply_defs_to_new_link) and need the relay datapath. + if getattr(self._project, "_marker_definitions", None): + return False + # Running nodes already have relay TAPs wired into uBridge; the + # kernel path needs the veth variant created at container start. + if node1.status != "stopped" or node2.status != "stopped": + return False + if _is_unix_socket_docker(node1) or _is_unix_socket_docker(node2): + return False + return True + async def _prepare(self): """ Local-only link setup: resolve peer addresses, reserve UDP ports and @@ -135,6 +181,22 @@ class UDPLink(Link): adapter_number2 = self._nodes[1]["adapter_number"] port_number2 = self._nodes[1]["port_number"] + if self._kernel_datapath_eligible(node1, node2): + # Kernel datapath: each endpoint enslaves its veth host end into + # a per-link Linux bridge. The name derives from the link id so + # both ends compute it independently (no coordinator) and crash + # leftovers sweep deterministically; 11 hex chars fill IFNAMSIZ + # (15), pushing name-collision probability to irrelevance. + bridge_name = "gns3" + self._id.replace("-", "")[:11] + self._link_data = [ + {"type": "nio_bridge", "bridge": bridge_name, "filters": {}, "markers": {}, "suspend": self._suspended}, + {"type": "nio_bridge", "bridge": bridge_name, "filters": {}, "markers": {}, "suspend": self._suspended}, + ] + return [ + (node1, adapter_number1, port_number1, self._link_data[0]), + (node2, adapter_number2, port_number2, self._link_data[1]), + ] + # Get an IP allowing communication between both host try: (node1_host, node2_host) = await node1.compute.get_ip_on_same_subnet(node2.compute) @@ -472,6 +534,12 @@ class UDPLink(Link): if name in self._markers: raise ControllerError(f"Marker '{name}' already exists on link {self._id}") + if self.kernel_datapath: + raise ControllerError( + "Markers are not supported on kernel-datapath links (no uBridge relay in the " + "forwarding path); delete and recreate the link to use markers" + ) + # Validate the BPF only for private per-link markers. An inherited copy # (``inherited_from`` set) fans out from a definition whose BPF was # already validated once at create/update (and on project load), so diff --git a/gns3server/schemas/__init__.py b/gns3server/schemas/__init__.py index cdc1d67ce..196ada3ab 100644 --- a/gns3server/schemas/__init__.py +++ b/gns3server/schemas/__init__.py @@ -101,7 +101,7 @@ from .compute.iou_nodes import IOU, IOUCreate, IOUStart, IOUUpdate from .compute.nat_nodes import NAT, NATCreate, NATUpdate # Compute schemas -from .compute.nios import TAPNIO, UDPNIO, BatchNIOCreate, BatchNIOEntry, EthernetNIO, MarkerRebuild, MarkerToggle +from .compute.nios import TAPNIO, UDPNIO, BridgeNIO, BatchNIOCreate, BatchNIOEntry, EthernetNIO, MarkerRebuild, MarkerToggle from .compute.qemu_nodes import Qemu, QemuCreate, QemuUpdate from .compute.virtualbox_nodes import VirtualBox, VirtualBoxCreate, VirtualBoxUpdate from .compute.vmware_nodes import VMware, VMwareCreate, VMwareUpdate @@ -166,6 +166,7 @@ __all__ = [ "AutoIdlePC", "BatchNIOCreate", "BatchNIOEntry", + "BridgeNIO", "C1700DynamipsTemplate", "C1700DynamipsTemplateUpdate", "C2600DynamipsTemplate", diff --git a/gns3server/schemas/compute/nios.py b/gns3server/schemas/compute/nios.py index 38078812c..10f8ab565 100644 --- a/gns3server/schemas/compute/nios.py +++ b/gns3server/schemas/compute/nios.py @@ -16,7 +16,7 @@ from enum import Enum -from typing import List, Optional +from typing import List, Optional, Union from pydantic import BaseModel, Field @@ -65,6 +65,27 @@ class TAPNIO(BaseModel): tap_device: str = Field(..., description="TAP device name e.g. tap0") +class BridgeNIOType(str, Enum): + + bridge = "nio_bridge" + + +class BridgeNIO(BaseModel): + """ + Kernel-datapath bridge Network Input/Output properties. The NIO tells the + node to enslave its veth host end into the named kernel bridge instead of + wiring a uBridge UDP relay. ``filters`` and ``markers`` must stay empty on + this datapath; they are accepted (and rejected server-side) only because + link update payloads always carry the full NIO dictionary. + """ + + type: BridgeNIOType + bridge: str = Field(..., description="Kernel bridge name e.g. gns3a1b2c3d4e5") + suspend: Optional[bool] = Field(None, description="Suspend the NIO") + filters: Optional[dict] = Field(None, description="Packet filters (unsupported, must be empty)") + markers: Optional[dict] = Field(None, description="Traffic-insight markers (unsupported, must be empty)") + + class MarkerToggle(BaseModel): """ Body for the per-marker enable/disable toggle endpoint: flips a running @@ -99,7 +120,7 @@ class BatchNIOEntry(BaseModel): node_id: str = Field(..., description="Node the NIO is attached to") adapter_number: int = Field(0, ge=0, description="Adapter number") port_number: int = Field(0, ge=0, description="Port number") - nio: UDPNIO = Field(..., description="NIO settings") + nio: Union[UDPNIO, BridgeNIO] = Field(..., description="NIO settings") class BatchNIOCreate(BaseModel): diff --git a/gns3server/schemas/config.py b/gns3server/schemas/config.py index f6c978911..fc83f39fd 100644 --- a/gns3server/schemas/config.py +++ b/gns3server/schemas/config.py @@ -183,6 +183,11 @@ class ServerSettings(BaseModel): description='Default symbol theme, e.g. "Classic" or "Affinity-square-blue"', ) allow_raw_images: bool = Field(True, description="Allow raw images to be uploaded to the server") + enable_kernel_datapath: bool = Field( + True, + description="Wire eligible Docker-to-Docker links (same compute, no filters/markers) through " + "kernel veth/bridge interfaces instead of the uBridge UDP relay", + ) 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") diff --git a/tests/compute/docker/test_docker_kernel_datapath.py b/tests/compute/docker/test_docker_kernel_datapath.py new file mode 100644 index 000000000..bc86e5fb5 --- /dev/null +++ b/tests/compute/docker/test_docker_kernel_datapath.py @@ -0,0 +1,373 @@ +# +# 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 . +# + +""" +Kernel-datapath (NIOBridge) adapter wiring for Docker containers: veth pair +creation, per-link kernel bridge enslavement, carrier semantics, relay +fallback and cleanup. +""" + +import uuid +from unittest.mock import MagicMock, call + +import pytest +import pytest_asyncio + +from tests.utils import AsyncioMagicMock + +from gns3server.compute.docker import Docker +from gns3server.compute.docker.docker_vm import DockerVM +from gns3server.compute.docker.docker_error import DockerError +from gns3server.compute.compute_error import ComputeError +from gns3server.compute.nios.nio_udp import NIOUDP +from gns3server.compute.nios.nio_bridge import NIOBridge +from gns3server.compute.ubridge.ubridge_error import UbridgeError + + +BRIDGE = "gns3a1b2c3d4e5f" + + +@pytest_asyncio.fixture +async def manager(port_manager): + + m = Docker.instance() + m.port_manager = port_manager + return m + + +@pytest_asyncio.fixture(scope="function") +async def vm(compute_project, manager): + + vm = DockerVM("test", str(uuid.uuid4()), compute_project, manager, "ubuntu:latest", aux_type="none") + vm._cid = "e90e34656842" + vm.mac_address = '02:42:3d:b7:93:00' + vm._start_interface_monitor = AsyncioMagicMock() + vm._stop_interface_monitor = AsyncioMagicMock() + return vm + + +# --------------------------------------------------------------------------- +# NIO factory +# --------------------------------------------------------------------------- + +def test_create_nio_bridge(vm): + + nio = vm.manager.create_nio({"type": "nio_bridge", "bridge": BRIDGE}) + assert isinstance(nio, NIOBridge) + assert nio.bridge == BRIDGE + assert nio.suspend is False + + +def test_create_nio_bridge_rejects_filters(vm): + + with pytest.raises(ComputeError): + vm.manager.create_nio({"type": "nio_bridge", "bridge": BRIDGE, "filters": {"loss": [10]}}) + + +def test_create_nio_bridge_rejects_markers(vm): + + with pytest.raises(ComputeError): + vm.manager.create_nio({"type": "nio_bridge", "bridge": BRIDGE, "markers": {"m": {"bpf": "icmp"}}}) + + +# --------------------------------------------------------------------------- +# Adapter wiring (start path) +# --------------------------------------------------------------------------- + +@pytest.mark.asyncio +async def test_add_ubridge_kernel_connection(vm): + + vm._ubridge_hypervisor = MagicMock() + vm._namespace = 42 + nio = vm.manager.create_nio({"type": "nio_bridge", "bridge": BRIDGE}) + + await vm._add_ubridge_connection(nio, 0) + + host_ifc, guest_ifc = vm._veth_names(0, 0) + calls = [ + # stale-pair sweep (no-ops on a clean host) + call.send(f'docker delete_veth "{host_ifc}"'), + call.send(f'docker delete_veth "{guest_ifc}"'), + call.send(f'docker create_veth "{host_ifc}" "{guest_ifc}"'), + call.send(f'link set "{host_ifc}" down'), + call.send(f'docker set_mac_addr {guest_ifc} 02:42:3d:b7:93:00'), + call.send(f'docker move_to_ns {guest_ifc} 42 eth0'), + # link attach + call.send(f'brctl create "{BRIDGE}"'), + call.send(f'link set "{BRIDGE}" up'), + call.send(f'brctl addif "{BRIDGE}" "{host_ifc}"'), + call.send(f'link set "{host_ifc}" up'), + ] + vm._ubridge_hypervisor.assert_has_calls(calls, any_order=True) + assert vm._kernel_veths[(0, 0)] == host_ifc + # no uBridge relay bridge on the kernel datapath + assert "bridge0" not in vm._bridges + + +@pytest.mark.asyncio +async def test_add_ubridge_connection_none_nio_stays_on_tap_path(vm): + """ + Unconnected adapters keep the relay TAP: the veth variant is only created + for adapters whose NIO is a kernel one at start time. + """ + + vm._ubridge_hypervisor = MagicMock() + vm._namespace = 42 + + await vm._add_ubridge_connection(None, 0) + + vm._ubridge_hypervisor.assert_has_calls([ + call.send("bridge create bridge0"), + call.send("bridge add_nio_tap bridge0 tap-gns3-e0 off"), + ], any_order=True) + assert vm._kernel_veths == {} + + +@pytest.mark.asyncio +async def test_add_ubridge_kernel_connection_cleans_up_on_failure(vm): + + vm._ubridge_hypervisor = MagicMock() + vm._namespace = 42 + + async def failing_send(command): + if "create_veth" in command and "delete" not in command: + raise UbridgeError("could not complete netlink transaction") + + vm._ubridge_send = AsyncioMagicMock(side_effect=failing_send) + + with pytest.raises(UbridgeError): + await vm._add_ubridge_connection(vm.manager.create_nio({"type": "nio_bridge", "bridge": BRIDGE}), 0) + + host_ifc, _ = vm._veth_names(0, 0) + vm._ubridge_send.assert_any_call(f'docker delete_veth "{host_ifc}"') + assert (0, 0) not in vm._kernel_veths + + +# --------------------------------------------------------------------------- +# Link attach / detach +# --------------------------------------------------------------------------- + +@pytest.mark.asyncio +async def test_connect_nio_kernel_tolerates_bridge_create_race(vm): + + # Both endpoints create the per-link bridge concurrently; the loser must + # verify (brctl show) instead of failing. + async def send(command): + if 'brctl create' in command: + raise UbridgeError("Could not create bridge: File exists") + + vm._ubridge_send = AsyncioMagicMock(side_effect=send) + host_ifc, _ = vm._veth_names(0, 0) + vm._kernel_veths[(0, 0)] = host_ifc + + nio = vm.manager.create_nio({"type": "nio_bridge", "bridge": BRIDGE}) + await vm._connect_nio(0, nio) + + vm._ubridge_send.assert_any_call(f'brctl show "{BRIDGE}"') + vm._ubridge_send.assert_any_call(f'brctl addif "{BRIDGE}" "{host_ifc}"') + + +@pytest.mark.asyncio +async def test_connect_nio_kernel_create_failure_propagates(vm): + + async def send(command): + if 'brctl create' in command or 'brctl show' in command: + raise UbridgeError("Could not create bridge: No such device") + + vm._ubridge_send = AsyncioMagicMock(side_effect=send) + vm._kernel_veths[(0, 0)] = vm._veth_names(0, 0)[0] + + nio = vm.manager.create_nio({"type": "nio_bridge", "bridge": BRIDGE}) + with pytest.raises(UbridgeError): + await vm._connect_nio(0, nio) + + +@pytest.mark.asyncio +async def test_connect_nio_kernel_without_veth_raises(vm): + + vm._ubridge_send = AsyncioMagicMock() + nio = vm.manager.create_nio({"type": "nio_bridge", "bridge": BRIDGE}) + with pytest.raises(DockerError, match="restart the node"): + await vm._connect_nio(0, nio) + + +@pytest.mark.asyncio +async def test_remove_kernel_nio(vm): + + vm._ubridge_hypervisor = MagicMock() + vm._ubridge_send = AsyncioMagicMock() + vm.status = "started" + host_ifc, _ = vm._veth_names(0, 0) + vm._kernel_veths[(0, 0)] = host_ifc + vm._ethernet_adapters[0].add_nio(0, vm.manager.create_nio({"type": "nio_bridge", "bridge": BRIDGE})) + + await vm.adapter_remove_nio_binding(0) + + vm._ubridge_send.assert_has_calls([ + call(f'link set "{host_ifc}" down'), + call(f'brctl delif "{BRIDGE}" "{host_ifc}"'), + call(f'brctl delete "{BRIDGE}"'), + ], any_order=True) + assert vm._ethernet_adapters[0].get_nio(0) is None + # the veth itself stays: it is the adapter interface, not the link + assert vm._kernel_veths[(0, 0)] == host_ifc + + +@pytest.mark.asyncio +async def test_remove_kernel_nio_tolerates_peer_winning_bridge_delete(vm): + + # Deleting a bridge that still holds the peer's port fails with EBUSY; + # a bridge already deleted by the peer fails with ENOENT — both expected. + vm._ubridge_hypervisor = MagicMock() + + async def send(command): + if 'brctl delif' in command or 'brctl delete' in command: + raise UbridgeError("busy") + + vm._ubridge_send = AsyncioMagicMock(side_effect=send) + vm._kernel_veths[(0, 0)] = vm._veth_names(0, 0)[0] + vm._ethernet_adapters[0].add_nio(0, vm.manager.create_nio({"type": "nio_bridge", "bridge": BRIDGE})) + + await vm.adapter_remove_nio_binding(0) # must not raise + + +# --------------------------------------------------------------------------- +# Carrier / suspend +# --------------------------------------------------------------------------- + +@pytest.mark.asyncio +async def test_set_adapter_carrier_kernel(vm): + + vm._ubridge_send = AsyncioMagicMock() + host_ifc, _ = vm._veth_names(0, 0) + vm._kernel_veths[(0, 0)] = host_ifc + + await vm._set_adapter_carrier(0, True) + await vm._set_adapter_carrier(0, False) + + vm._ubridge_send.assert_has_calls([ + call(f'link set "{host_ifc}" up'), + call(f'link set "{host_ifc}" down'), + ]) + + +# --------------------------------------------------------------------------- +# UDP fallback on a veth adapter (kernel link deleted, relay link created +# on a running node) +# --------------------------------------------------------------------------- + +@pytest.mark.asyncio +async def test_connect_nio_udp_relays_over_veth(vm): + + vm._ubridge_send = AsyncioMagicMock() + host_ifc, _ = vm._veth_names(0, 0) + vm._kernel_veths[(0, 0)] = host_ifc + + nio = vm.manager.create_nio({"type": "nio_udp", "lport": 4242, "rport": 4343, "rhost": "127.0.0.1"}) + await vm._connect_nio(0, nio) + + vm._ubridge_send.assert_has_calls([ + call("bridge create bridge0"), + call(f'bridge add_nio_ethernet bridge0 "{host_ifc}"'), + call("bridge add_nio_udp bridge0 4242 127.0.0.1 4343"), + call("bridge start bridge0"), + ], any_order=True) + assert "bridge0" in vm._bridges + + +@pytest.mark.asyncio +async def test_connect_nio_udp_on_veth_is_idempotent(vm): + + vm._ubridge_send = AsyncioMagicMock() + host_ifc, _ = vm._veth_names(0, 0) + vm._kernel_veths[(0, 0)] = host_ifc + vm._bridges.add("bridge0") # relay already exists from a previous link + + nio = vm.manager.create_nio({"type": "nio_udp", "lport": 4242, "rport": 4343, "rhost": "127.0.0.1"}) + await vm._connect_nio(0, nio) + + for c in vm._ubridge_send.call_args_list: + command = c.args[0] + assert "bridge create" not in command + assert "add_nio_ethernet" not in command + + +# --------------------------------------------------------------------------- +# Guards and cleanup +# --------------------------------------------------------------------------- + +@pytest.mark.asyncio +async def test_update_nio_kernel_rejects_filters(vm): + + vm._ubridge_hypervisor = MagicMock() + nio = vm.manager.create_nio({"type": "nio_bridge", "bridge": BRIDGE}) + vm._ethernet_adapters[0].add_nio(0, nio) + nio.filters = {"loss": [50]} + + with pytest.raises(DockerError, match="kernel-datapath"): + await vm.adapter_update_nio_binding(0, nio) + + +@pytest.mark.asyncio +async def test_update_nio_kernel_suspend_toggles_carrier(vm): + + vm._ubridge_hypervisor = MagicMock() + vm._ubridge_send = AsyncioMagicMock() + vm.status = "started" + host_ifc, _ = vm._veth_names(0, 0) + vm._kernel_veths[(0, 0)] = host_ifc + nio = vm.manager.create_nio({"type": "nio_bridge", "bridge": BRIDGE}) + vm._ethernet_adapters[0].add_nio(0, nio) + + nio.suspend = True + await vm.adapter_update_nio_binding(0, nio) + vm._ubridge_send.assert_any_call(f'link set "{host_ifc}" down') + + +@pytest.mark.asyncio +async def test_start_capture_rejected_on_kernel_link(vm): + + nio = vm.manager.create_nio({"type": "nio_bridge", "bridge": BRIDGE}) + vm._ethernet_adapters[0].add_nio(0, nio) + + with pytest.raises(DockerError, match="kernel-datapath"): + await vm.start_capture(0, "/tmp/capture.pcap") + + +@pytest.mark.asyncio +async def test_remove_kernel_veths(vm): + + vm._ubridge_hypervisor = MagicMock() + vm._ubridge_send = AsyncioMagicMock() + host_ifc, _ = vm._veth_names(0, 0) + vm._kernel_veths[(0, 0)] = host_ifc + + await vm._remove_kernel_veths() + + vm._ubridge_send.assert_called_once_with(f'docker delete_veth "{host_ifc}"') + assert vm._kernel_veths == {} + + +def test_veth_names_deterministic_and_bounded(vm): + + host_ifc, guest_ifc = vm._veth_names(3, 2) + assert host_ifc == f"gv{vm.id.replace('-', '')[:8]}e3p2" + assert guest_ifc == f"gc{vm.id.replace('-', '')[:8]}e3p2" + assert host_ifc != guest_ifc + # multi-port adapters can reach two-digit adapter/port numbers + long_host, long_guest = vm._veth_names(12, 8) + assert len(long_host) <= 15 and len(long_guest) <= 15 diff --git a/tests/compute/docker/test_vendor_docker_vm.py b/tests/compute/docker/test_vendor_docker_vm.py index e594cf5fa..93ef04905 100644 --- a/tests/compute/docker/test_vendor_docker_vm.py +++ b/tests/compute/docker/test_vendor_docker_vm.py @@ -902,3 +902,18 @@ async def test_create_reparse_refreshes_env_knobs(compute_project, manager): assert vm._stop_timeout == 5 assert vm._gns3_init is True # removed entry reset to default + + +# --------------------------------------------------------------------------- +# Kernel-datapath (NIOBridge) rejection +# --------------------------------------------------------------------------- + +@pytest.mark.asyncio +async def test_unix_socket_nio_rejects_kernel_datapath_link(compute_project, manager): + + vm = _make_vm(compute_project, manager, environment="GNS3_UNIX_SOCKET_NIO=1") + vm._ubridge_hypervisor = MagicMock() + vm._namespace = 42 + nio = manager.create_nio({"type": "nio_bridge", "bridge": "gns3a1b2c3d4e5f"}) + with pytest.raises(DockerError, match="kernel-datapath"): + await vm._add_ubridge_connection(nio, 0) diff --git a/tests/controller/test_kernel_datapath_link.py b/tests/controller/test_kernel_datapath_link.py new file mode 100644 index 000000000..f5e9dc938 --- /dev/null +++ b/tests/controller/test_kernel_datapath_link.py @@ -0,0 +1,227 @@ +# +# 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 . +# + +""" +Controller-side kernel-datapath link selection: eligibility of Docker-to- +Docker links, NIO emission and the filters/markers guards. +""" + +import pytest +from unittest.mock import MagicMock + +from gns3server.config import Config +from gns3server.controller.udp_link import UDPLink +from gns3server.controller.ports.ethernet_port import EthernetPort +from gns3server.controller.node import Node +from gns3server.controller.controller_error import ControllerError + + +def _node(project, compute, name, node_type="docker", status="stopped", environment=None): + node = Node(project, compute, name, node_type=node_type) + node._ports = [EthernetPort("eth0", 0, 0, 0)] + node._status = status + if environment is not None: + node._properties = {"environment": environment} + return node + + +async def _kernel_link(project, **kwargs): + compute = MagicMock() + compute.id = "compute-1" + node1 = _node(project, compute, "docker1", **kwargs) + node2 = _node(project, compute, "docker2", **kwargs) + link = UDPLink(project) + # batch=True: skip the interactive create() (the test drives _prepare() + # itself) — matches the project-open bulk path. + await link.add_node(node1, 0, 0, batch=True, dump=False) + await link.add_node(node2, 0, 0, batch=True, dump=False) + return link, node1, node2 + + +@pytest.mark.asyncio +async def test_kernel_datapath_eligible(project): + + link, node1, node2 = await _kernel_link(project) + assert link._kernel_datapath_eligible(node1, node2) is True + + +@pytest.mark.asyncio +async def test_kernel_datapath_not_eligible_for_other_node_types(project): + + compute = MagicMock() + compute.id = "compute-1" + node1 = _node(project, compute, "docker1") + node2 = _node(project, compute, "qemu1", node_type="qemu") + link = UDPLink(project) + await link.add_node(node1, 0, 0, batch=True, dump=False) + await link.add_node(node2, 0, 0, batch=True, dump=False) + assert link._kernel_datapath_eligible(node1, node2) is False + + +@pytest.mark.asyncio +async def test_kernel_datapath_not_eligible_across_computes(project): + + compute1 = MagicMock() + compute1.id = "compute-1" + compute2 = MagicMock() + compute2.id = "compute-2" + node1 = _node(project, compute1, "docker1") + node2 = _node(project, compute2, "docker2") + link = UDPLink(project) + await link.add_node(node1, 0, 0, batch=True, dump=False) + await link.add_node(node2, 0, 0, batch=True, dump=False) + assert link._kernel_datapath_eligible(node1, node2) is False + + +@pytest.mark.asyncio +async def test_kernel_datapath_not_eligible_with_filters(project): + + link, node1, node2 = await _kernel_link(project) + link._filters = {"delay": [10, 0]} + assert link._kernel_datapath_eligible(node1, node2) is False + + +@pytest.mark.asyncio +async def test_kernel_datapath_not_eligible_with_markers(project): + + link, node1, node2 = await _kernel_link(project) + link._markers = {"m": {"bpf": "icmp"}} + assert link._kernel_datapath_eligible(node1, node2) is False + + +@pytest.mark.asyncio +async def test_kernel_datapath_not_eligible_with_project_marker_definitions(project): + + link, node1, node2 = await _kernel_link(project) + project._marker_definitions = {"global-m": {"bpf": "icmp"}} + try: + assert link._kernel_datapath_eligible(node1, node2) is False + finally: + project._marker_definitions = {} + + +@pytest.mark.asyncio +async def test_kernel_datapath_not_eligible_for_running_nodes(project): + + link, node1, node2 = await _kernel_link(project) + node1._status = "started" + assert link._kernel_datapath_eligible(node1, node2) is False + + +@pytest.mark.asyncio +async def test_kernel_datapath_not_eligible_for_unix_socket_containers(project): + + link, node1, node2 = await _kernel_link(project) + node2._properties = {"environment": "GNS3_UNIX_SOCKET_NIO=1"} + assert link._kernel_datapath_eligible(node1, node2) is False + + +@pytest.mark.asyncio +async def test_kernel_datapath_disabled_by_config(project): + + settings = Config.instance().settings.Server + settings.enable_kernel_datapath = False + try: + link, node1, node2 = await _kernel_link(project) + assert link._kernel_datapath_eligible(node1, node2) is False + finally: + settings.enable_kernel_datapath = True + + +@pytest.mark.asyncio +async def test_prepare_kernel_link_emits_bridge_nios(project): + + compute = MagicMock() + compute.id = "compute-1" + node1 = _node(project, compute, "docker1") + node2 = _node(project, compute, "docker2") + link = UDPLink(project) + await link.add_node(node1, 0, 0, batch=True, dump=False) + await link.add_node(node2, 0, 0, batch=True, dump=False) + + entries = await link._prepare() + + assert len(entries) == 2 + expected_bridge = "gns3" + link.id.replace("-", "")[:11] + assert len(expected_bridge) == 15 + for entry in entries: + assert entry[3]["type"] == "nio_bridge" + assert entry[3]["bridge"] == expected_bridge + # no UDP port reservation on the kernel datapath + compute.post.assert_not_called() + + +@pytest.mark.asyncio +async def test_prepare_relay_link_allocates_udp_ports(project): + + compute1 = MagicMock() + compute1.id = "compute-1" + compute2 = MagicMock() + compute2.id = "compute-2" + + async def subnet_callback(other): + return ("192.168.1.1", "192.168.1.2") + + compute1.get_ip_on_same_subnet.side_effect = subnet_callback + + async def port_callback(path, data={}, **kwargs): + response = MagicMock() + response.json = {"udp_port": 1024} + return response + + compute1.post.side_effect = port_callback + compute2.post.side_effect = port_callback + + node1 = _node(project, compute1, "docker1") + node2 = _node(project, compute2, "docker2") + link = UDPLink(project) + await link.add_node(node1, 0, 0, batch=True, dump=False) + await link.add_node(node2, 0, 0, batch=True, dump=False) + + entries = await link._prepare() + + for entry in entries: + assert entry[3]["type"] == "nio_udp" + + +@pytest.mark.asyncio +async def test_update_filters_rejected_on_kernel_link(project): + + link, _n1, _n2 = await _kernel_link(project) + await link._prepare() + + with pytest.raises(ControllerError, match="kernel-datapath"): + await link.update_filters({"delay": [10, 0]}) + + +@pytest.mark.asyncio +async def test_start_marker_rejected_on_kernel_link(project): + + link, _n1, _n2 = await _kernel_link(project) + await link._prepare() + + with pytest.raises(ControllerError, match="kernel-datapath"): + await link.start_marker("m", "icmp") + + +@pytest.mark.asyncio +async def test_kernel_datapath_property(project): + + link, _n1, _n2 = await _kernel_link(project) + assert link.kernel_datapath is False + await link._prepare() + assert link.kernel_datapath is True