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)
This commit is contained in:
YueGuobin 2026-09-28 00:21:58 +08:00
parent 65c858d455
commit 37d7b9121e
No known key found for this signature in database
13 changed files with 1031 additions and 21 deletions

View File

@ -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.

View File

@ -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):

View File

@ -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:

View File

@ -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:

View File

@ -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 <http://www.gnu.org/licenses/>.
#
"""
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,
}

View File

@ -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:

View File

@ -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

View File

@ -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",

View File

@ -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):

View File

@ -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")

View File

@ -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 <http://www.gnu.org/licenses/>.
#
"""
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

View File

@ -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)

View File

@ -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 <http://www.gnu.org/licenses/>.
#
"""
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