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