feat(cloud): attach TAP interfaces to existing Linux bridges

This commit is contained in:
Cristi committed 2026-10-19 15:42:18 +03:00
1 parent e57acfb7b1
commit a9f5306f16
5 files changed
+253 -9

No files matched your search

+4 -3
View File
@@ -297,11 +297,12 @@ class BaseManager:
return node
@staticmethod
def has_privileged_access(executable):
def has_privileged_access(executable, *, require_net_admin=False):
"""
Check if an executable have the right to attach to Ethernet and TAP adapters.
:param executable: executable path
:param require_net_admin: also require CAP_NET_ADMIN for network configuration
:returns: True or False
"""
@@ -324,8 +325,8 @@ class BaseManager:
try:
if sys.platform.startswith("linux") and "security.capability" in os.listxattr(executable):
caps = os.getxattr(executable, "security.capability")
# test the 2nd byte and check if the 13th bit (CAP_NET_RAW) is set
if struct.unpack("<IIIII", caps)[1] & 1 << 13:
required = (1 << 13) | ((1 << 12) if require_net_admin else 0)
if struct.unpack("<IIIII", caps)[1] & required == required:
return True
except (AttributeError, OSError) as e:
log.error(f"Could not determine if CAP_NET_RAW capability is set for {executable}: {e}")
+43 -5
View File
@@ -15,12 +15,15 @@
# along with this program. If not, see <http://www.gnu.org/licenses/>.
import logging
import os
import re
import subprocess
import sys
import gns3server.utils.asyncio
import gns3server.utils.interfaces
from gns3server.compute.ubridge.ubridge_error import UbridgeError
from gns3server.schemas.compute.cloud_nodes import LINUX_TAP_NAME_PATTERN
from gns3server.utils.kernel_anchor import is_kernel_datapath_interface
from ...base_node import BaseNode
@@ -69,7 +72,7 @@ class Cloud(BaseNode):
for port in ports:
port["port_number"] = port_number
port_number += 1
self._ports_mapping = ports
self._ports_mapping = self._normalize_ports_mapping(ports)
@property
def nios(self):
@@ -101,6 +104,9 @@ class Cloud(BaseNode):
}
)
if sys.platform.startswith("linux") and gns3server.utils.interfaces.is_interface_bridge(interface["name"]):
host_interfaces[-1]["is_bridge"] = True
return {
"name": self.name,
"usage": self.usage,
@@ -206,6 +212,11 @@ class Cloud(BaseNode):
return self._ports_mapping
@staticmethod
def _normalize_ports_mapping(ports):
# API responses include bridge=None; legacy projects omit it.
return [{key: value for key, value in port.items() if key != "bridge" or value is not None} for port in ports]
@ports_mapping.setter
def ports_mapping(self, ports):
"""
@@ -214,6 +225,7 @@ class Cloud(BaseNode):
:param ports: ports info
"""
ports = self._normalize_ports_mapping(ports)
if ports != self._ports_mapping:
if len(self._nios) > 0:
raise NodeError("Cannot modify a cloud that is already connected.")
@@ -314,7 +326,9 @@ class Cloud(BaseNode):
await self._ubridge_apply_filters(bridge_name, nio.filters)
await self._ubridge_apply_markers(bridge_name, nio)
if port_info["type"] in ("ethernet", "tap"):
if not self.manager.has_privileged_access(self.ubridge_path):
if not self.manager.has_privileged_access(
self.ubridge_path, require_net_admin=bool(port_info.get("bridge"))
):
raise NodeError(
"uBridge requires root access or the capability to interact with Ethernet and TAP adapters"
)
@@ -334,9 +348,7 @@ class Cloud(BaseNode):
await self._add_windows_ethernet(port_info, bridge_name)
elif port_info["type"] == "tap":
await self._ubridge_send(
'bridge add_nio_tap {name} "{interface}"'.format(name=bridge_name, interface=port_info["interface"])
)
await self._add_tap_interface(port_info, bridge_name)
elif port_info["type"] == "udp":
await self._ubridge_send(
@@ -350,6 +362,32 @@ class Cloud(BaseNode):
await self._ubridge_send(f"bridge start {bridge_name}")
async def _add_tap_interface(self, port_info, bridge_name):
"""Open a TAP and restore its optional host bridge assignment."""
target_bridge = port_info.get("bridge")
if target_bridge:
self._validate_tap_bridge(port_info["interface"], target_bridge)
await self._ubridge_send(
'bridge add_nio_tap {name} "{interface}"'.format(name=bridge_name, interface=port_info["interface"])
)
if target_bridge:
await self._ubridge_send(f'brctl addif "{target_bridge}" "{port_info["interface"]}"')
def _validate_tap_bridge(self, tap, target_bridge):
"""Validate host resources before creating or attaching a Cloud TAP."""
if not sys.platform.startswith("linux"):
raise NodeError("TAP bridge assignments are only supported on Linux")
if not all(re.fullmatch(LINUX_TAP_NAME_PATTERN, name) for name in (target_bridge, tap)):
raise NodeError("Invalid TAP or bridge interface name")
if target_bridge not in {
interface["name"] for interface in self._interfaces()
} or not gns3server.utils.interfaces.is_interface_bridge(target_bridge):
raise NodeError(f"Bridge '{target_bridge}' is missing or not allowed on this server")
master_path = os.path.join("/sys/class/net", tap, "master")
if os.path.exists(master_path) and os.path.basename(os.path.realpath(master_path)) != target_bridge:
raise NodeError(f"TAP '{tap}' already belongs to another bridge")
async def _add_linux_ethernet(self, port_info, bridge_name):
"""
Connects an Ethernet interface on Linux using raw sockets.
+18 -1
View File
@@ -14,15 +14,18 @@
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
import re
from enum import Enum
from typing import List, Optional, Union
from uuid import UUID
from pydantic import BaseModel, Field
from pydantic import BaseModel, Field, model_validator
from ..common import NodeStatus
from ..update import PartialUpdateModel
LINUX_TAP_NAME_PATTERN = r"^[a-zA-Z0-9_.:-]{1,15}$"
class HostInterfaceType(str, Enum):
ethernet = "ethernet"
@@ -57,6 +60,7 @@ class HostInterface(BaseModel):
name: str = Field(..., description="Interface name")
type: HostInterfaceType = Field(..., description="Interface type")
special: bool = Field(..., description="Whether the interface is non standard")
is_bridge: bool = Field(False, description="Whether this is a Linux bridge")
ip_addresses: List[HostInterfaceIPAddress] = Field(
default_factory=list, description="All IPv4 and IPv6 addresses on this interface"
)
@@ -94,6 +98,19 @@ class TAPPort(BaseModel):
port_number: int
type: TAPType
interface: str
bridge: Optional[str] = Field(
None,
min_length=1,
max_length=15,
pattern=LINUX_TAP_NAME_PATTERN,
description="Existing Linux bridge to attach the TAP to",
)
@model_validator(mode="after")
def validate_bridged_interface(self):
if self.bridge and not re.fullmatch(LINUX_TAP_NAME_PATTERN, self.interface):
raise ValueError("A bridged TAP name must be 1-15 letters, digits, underscores, dots, colons or hyphens")
return self
class UDPType(str, Enum):