mirror of
https://github.com/GNS3/gns3-server.git
synced 2026-10-12 21:45:41 +03:00
Switch-to-switch links were the last shape left on the relay: the switch fast path works by absorbing the *peer's* anchor, and a switch has none — its ports are its own bridge, and a kernel interface belongs to exactly one bridge, so two bridges cannot be joined by enslaving anything. Join them with a veth pair instead: one end per bridge, each side enslaving its own end and applying its own port mode to it — an access port on one side and a trunk on the other behaves like the real cable it models. uBridge's docker create_veth/delete_veth (the primitive every Docker adapter is already born from) creates and destroys the pair; the names derive from the link id (utils.kernel_anchor.kernel_cascade_names), so each side computes its own end and the names survive a restart. The pair belongs to the link, and the two ends are wired by two independent uBridge processes — the project-open batch drives different nodes concurrently — so the protocol is order-free: both sides create the pair when their own end is missing (one create wins; the loser's failure is re-checked against the kernel and "the pair is there" is not an error), and either side's teardown destroys it (deleting one veth end takes the other with it, so both converge on the same gone pair and the second delif answers ENOENT). A pre-existing end — this switch restarted, or a leftover from a previous life of the link id — is reused rather than recreated, because the other end may still be a live port of the peer switch; a stale tc qdisc is all a reused end can carry. No new wire format was needed: a cascade is an anchor NIO on both ends, the absorbed-anchor case plus the peer end's name. That also corrects the filter routing — the single-sided rule exists because an absorbed anchor is one interface, while a cascade has one per switch, so impairments go to both ends like any other kernel link. Tests: the wire format, the eligibility flip and the both-ends re-push on node start (controller); the naming contract (utils); the switch-side lifecycle — create, reuse, lost create race, delete destroys the pair (compute routes). Drive-by: the relay filter test's compute mock carried no awaitable `put`, so tests/controller/test_kernel_datapath_link.py failed when run alone while an argument-less full run passed; callers now get one.
583 lines
20 KiB
Python
583 lines
20 KiB
Python
#
|
|
# Copyright (C) 2015 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/>.
|
|
|
|
import asyncio
|
|
import inspect
|
|
import logging
|
|
import os
|
|
import re
|
|
import shutil
|
|
import socket
|
|
import stat
|
|
import struct
|
|
import sys
|
|
|
|
import aiofiles
|
|
|
|
log = logging.getLogger(__name__)
|
|
|
|
from typing import Type
|
|
from uuid import UUID, uuid4
|
|
|
|
from gns3server.compute.compute_error import ComputeError, ComputeForbiddenError, ComputeNotFoundError
|
|
from gns3server.utils.asyncio import cancellable_wait_run_in_executor
|
|
from gns3server.utils.interfaces import is_interface_up
|
|
|
|
from ..config import Config
|
|
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_anchor import NIOAnchor
|
|
from .nios.nio_bridge import NIOBridge
|
|
from .nios.nio_ethernet import NIOEthernet
|
|
from .nios.nio_tap import NIOTAP
|
|
from .nios.nio_udp import NIOUDP
|
|
from .port_manager import PortManager
|
|
from .project_manager import ProjectManager
|
|
|
|
CHUNK_SIZE = 1024 * 8 # 8KB
|
|
|
|
|
|
class BaseManager:
|
|
"""
|
|
Base class for all Manager classes.
|
|
Responsible of management of a node pool of the same type.
|
|
"""
|
|
|
|
_convert_lock = None
|
|
|
|
def __init__(self):
|
|
|
|
BaseManager._convert_lock = asyncio.Lock()
|
|
self._nodes = {}
|
|
self._port_manager = None
|
|
self._config = Config.instance()
|
|
|
|
@classmethod
|
|
def node_types(cls):
|
|
"""
|
|
:returns: Array of supported node type on this computer
|
|
"""
|
|
|
|
# By default we transform DockerVM => docker but you can override this (see builtins)
|
|
return [cls._NODE_CLASS.__name__.rstrip("VM").lower()]
|
|
|
|
@property
|
|
def nodes(self):
|
|
"""
|
|
List of nodes manage by the module
|
|
"""
|
|
|
|
return self._nodes.values()
|
|
|
|
@classmethod
|
|
def instance(cls):
|
|
"""
|
|
Singleton to return only one instance of BaseManager.
|
|
|
|
:returns: instance of BaseManager
|
|
"""
|
|
|
|
if not hasattr(cls, "_instance") or cls._instance is None:
|
|
cls._instance = cls()
|
|
return cls._instance
|
|
|
|
@property
|
|
def module_name(self):
|
|
"""
|
|
Returns the module name.
|
|
|
|
:returns: module name
|
|
"""
|
|
|
|
return self.__class__.__name__
|
|
|
|
@property
|
|
def port_manager(self):
|
|
"""
|
|
Returns the port manager.
|
|
|
|
:returns: Port manager
|
|
"""
|
|
|
|
if self._port_manager is None:
|
|
self._port_manager = PortManager.instance()
|
|
return self._port_manager
|
|
|
|
@port_manager.setter
|
|
def port_manager(self, new_port_manager):
|
|
|
|
self._port_manager = new_port_manager
|
|
|
|
@property
|
|
def config(self):
|
|
"""
|
|
Returns the server config.
|
|
|
|
:returns: Config
|
|
"""
|
|
|
|
return self._config
|
|
|
|
async def unload(self):
|
|
|
|
tasks = []
|
|
for node_id in self._nodes.keys():
|
|
tasks.append(asyncio.ensure_future(self.close_node(node_id)))
|
|
|
|
if tasks:
|
|
done, _ = await asyncio.wait(tasks)
|
|
for future in done:
|
|
try:
|
|
future.result()
|
|
except (Exception, GeneratorExit) as e:
|
|
log.error(f"Could not close node: {e}", exc_info=1)
|
|
continue
|
|
|
|
if hasattr(BaseManager, "_instance"):
|
|
BaseManager._instance = None
|
|
log.debug(f"Module {self.module_name} unloaded")
|
|
|
|
def get_node(self, node_id, project_id=None) -> Type[BaseNode]:
|
|
"""
|
|
Returns a Node instance.
|
|
|
|
:param node_id: Node identifier
|
|
:param project_id: Project identifier
|
|
|
|
:returns: Node instance
|
|
"""
|
|
|
|
if project_id:
|
|
# check the project_id exists
|
|
project = ProjectManager.instance().get_project(project_id)
|
|
|
|
try:
|
|
UUID(node_id, version=4)
|
|
except ValueError:
|
|
raise ComputeError(f"Node ID {node_id} is not a valid UUID")
|
|
|
|
if node_id not in self._nodes:
|
|
raise ComputeNotFoundError(f"Node ID {node_id} doesn't exist")
|
|
|
|
node = self._nodes[node_id]
|
|
if project_id:
|
|
if node.project.id != project.id:
|
|
raise ComputeNotFoundError("Project ID {project_id} doesn't belong to node {node.name}")
|
|
|
|
return node
|
|
|
|
async def create_node(self, name, project_id, node_id, *args, **kwargs):
|
|
"""
|
|
Create a new node
|
|
|
|
:param name: Node name
|
|
:param project_id: Project identifier
|
|
:param node_id: restore a node identifier
|
|
"""
|
|
|
|
if node_id in self._nodes:
|
|
return self._nodes[node_id]
|
|
|
|
project = ProjectManager.instance().get_project(project_id)
|
|
if not node_id:
|
|
node_id = str(uuid4())
|
|
|
|
node = self._NODE_CLASS(name, node_id, project, self, *args, **kwargs)
|
|
if inspect.iscoroutinefunction(node.create):
|
|
await node.create()
|
|
else:
|
|
node.create()
|
|
self._nodes[node.id] = node
|
|
project.add_node(node)
|
|
return node
|
|
|
|
async def duplicate_node(self, source_node_id, destination_node_id):
|
|
"""
|
|
Duplicate a node
|
|
|
|
:param source_node_id: Source node identifier
|
|
:param destination_node_id: Destination node identifier
|
|
:returns: New node instance
|
|
"""
|
|
|
|
source_node = self.get_node(source_node_id)
|
|
destination_node = self.get_node(destination_node_id)
|
|
|
|
# Some node don't have working dir like switch
|
|
if not hasattr(destination_node, "working_dir"):
|
|
return destination_node
|
|
|
|
if hasattr(source_node, "status") and source_node.status != "stopped":
|
|
raise ComputeError("Cannot duplicate node data while the node is running")
|
|
|
|
destination_dir = destination_node.working_dir
|
|
try:
|
|
shutil.rmtree(destination_dir)
|
|
shutil.copytree(source_node.working_dir, destination_dir, symlinks=True, ignore_dangling_symlinks=True)
|
|
except OSError as e:
|
|
raise ComputeError(f"Cannot duplicate node data: {e}")
|
|
|
|
# We force a refresh of the name. This forces the rewrite
|
|
# of some configuration files
|
|
node_name = destination_node.name
|
|
destination_node.name = node_name + str(uuid4())
|
|
destination_node.name = node_name
|
|
|
|
return destination_node
|
|
|
|
async def close_node(self, node_id):
|
|
"""
|
|
Close a node
|
|
|
|
:param node_id: Node identifier
|
|
|
|
:returns: Node instance
|
|
"""
|
|
|
|
node = self.get_node(node_id)
|
|
if inspect.iscoroutinefunction(node.close):
|
|
await node.close()
|
|
else:
|
|
node.close()
|
|
return node
|
|
|
|
async def project_closing(self, project):
|
|
"""
|
|
Called when a project is about to be closed.
|
|
|
|
:param project: Project instance
|
|
"""
|
|
|
|
pass
|
|
|
|
async def project_closed(self, project):
|
|
"""
|
|
Called when a project is closed.
|
|
|
|
:param project: Project instance
|
|
"""
|
|
|
|
for node in project.nodes:
|
|
if node.id in self._nodes:
|
|
del self._nodes[node.id]
|
|
|
|
async def delete_node(self, node_id):
|
|
"""
|
|
Delete a node. The node working directory will be destroyed when a commit is received.
|
|
|
|
:param node_id: Node identifier
|
|
:returns: Node instance
|
|
"""
|
|
|
|
node = None
|
|
try:
|
|
node = self.get_node(node_id)
|
|
await self.close_node(node_id)
|
|
finally:
|
|
if node:
|
|
node.project.emit("node.deleted", node)
|
|
await node.project.remove_node(node)
|
|
if node.id in self._nodes:
|
|
del self._nodes[node.id]
|
|
return node
|
|
|
|
@staticmethod
|
|
def has_privileged_access(executable):
|
|
"""
|
|
Check if an executable have the right to attach to Ethernet and TAP adapters.
|
|
|
|
:param executable: executable path
|
|
|
|
:returns: True or False
|
|
"""
|
|
|
|
if sys.platform.startswith("darwin"):
|
|
if os.stat(executable).st_uid == 0:
|
|
return True
|
|
|
|
if os.geteuid() == 0:
|
|
# we are root, so we should have privileged access.
|
|
return True
|
|
|
|
if os.stat(executable).st_uid == 0 and (
|
|
os.stat(executable).st_mode & stat.S_ISUID or os.stat(executable).st_mode & stat.S_ISGID
|
|
):
|
|
# the executable has set UID bit.
|
|
return True
|
|
|
|
# test if the executable has the CAP_NET_RAW capability (Linux only)
|
|
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:
|
|
return True
|
|
except (AttributeError, OSError) as e:
|
|
log.error(f"Could not determine if CAP_NET_RAW capability is set for {executable}: {e}")
|
|
|
|
return False
|
|
|
|
def create_nio(self, nio_settings):
|
|
"""
|
|
Creates a new NIO.
|
|
|
|
:param nio_settings: information to create the NIO
|
|
|
|
:returns: a NIO object
|
|
"""
|
|
|
|
nio = None
|
|
if nio_settings["type"] == "nio_udp":
|
|
lport = nio_settings["lport"]
|
|
rhost = nio_settings["rhost"]
|
|
rport = nio_settings["rport"]
|
|
try:
|
|
info = socket.getaddrinfo(rhost, rport, socket.AF_UNSPEC, socket.SOCK_DGRAM, 0, socket.AI_PASSIVE)
|
|
if not info:
|
|
raise ComputeError(f"getaddrinfo returned an empty list on {rhost}:{rport}")
|
|
for res in info:
|
|
af, socktype, proto, _, sa = res
|
|
with socket.socket(af, socktype, proto) as sock:
|
|
sock.connect(sa)
|
|
except OSError as e:
|
|
raise ComputeError(f"Could not create an UDP connection to {rhost}:{rport}: {e}")
|
|
nio = NIOUDP(lport, rhost, rport)
|
|
# The netem-extension filters (rate, reorder, gemodel…) have no
|
|
# uBridge relay equivalent — reject them here (the controller
|
|
# already keeps them off relay links; this is the second guard
|
|
# for direct compute API use, mirroring the kernel-NIO guard).
|
|
from gns3server.utils.packet_filter_validation import kernel_only_features
|
|
|
|
kernel_only = kernel_only_features(nio_settings.get("filters") or {})
|
|
if kernel_only:
|
|
raise ComputeError(
|
|
"Packet filter(s) {} only run on a kernel-datapath link "
|
|
"(tc netem on the veth host end); the uBridge relay has no equivalent".format(
|
|
", ".join(sorted(kernel_only))
|
|
)
|
|
)
|
|
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"])
|
|
nio.filters = nio_settings.get("filters", {})
|
|
# Markers ride the NIO and attach to the veth host interface via
|
|
# uBridge's AF_PACKET marker module (marker add_kernel).
|
|
nio.markers = nio_settings.get("markers", {})
|
|
nio.suspend = nio_settings.get("suspend", False)
|
|
elif nio_settings["type"] == "nio_anchor":
|
|
# The node-owned-bridge mirror of nio_bridge: the Ethernet
|
|
# switch joins the named foreign anchor to its own kernel
|
|
# bridge (AnchorNIO/NIOAnchor). A cascade link carries the
|
|
# other veth end's name in "peer" on the owning side.
|
|
nio = NIOAnchor(nio_settings["anchor"], nio_settings.get("peer"))
|
|
nio.filters = nio_settings.get("filters", {})
|
|
nio.markers = nio_settings.get("markers", {})
|
|
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):
|
|
# raise aiohttp.web.HTTPConflict(text="TAP interface {} does not exist or is down".format(tap_device))
|
|
# FIXME: check for permissions on tap device
|
|
# if not self.has_privileged_access(executable):
|
|
# raise aiohttp.web.HTTPForbidden(text="{} has no privileged access to {}.".format(executable, tap_device))
|
|
nio = NIOTAP(tap_device)
|
|
elif nio_settings["type"] in ("nio_generic_ethernet", "nio_ethernet"):
|
|
ethernet_device = nio_settings["ethernet_device"]
|
|
if not is_interface_up(ethernet_device):
|
|
raise ComputeError(f"Ethernet interface {ethernet_device} does not exist or is down")
|
|
nio = NIOEthernet(ethernet_device)
|
|
assert nio is not None
|
|
return nio
|
|
|
|
async def stream_pcap_file(self, nio, project_id):
|
|
"""
|
|
Streams a PCAP file.
|
|
|
|
:param nio: NIO object
|
|
:param project_id: Project identifier
|
|
"""
|
|
|
|
if not nio.capturing:
|
|
raise ComputeError("Nothing to stream because there is no packet capture active")
|
|
|
|
project = ProjectManager.instance().get_project(project_id)
|
|
path = os.path.normpath(os.path.join(project.capture_working_directory(), nio.pcap_output_file))
|
|
|
|
# Raise an error if user try to escape
|
|
if path[0] == ".":
|
|
raise ComputeForbiddenError("Cannot stream PCAP file outside the capture working directory")
|
|
|
|
try:
|
|
with open(path, "rb") as f:
|
|
while nio.capturing:
|
|
data = f.read(CHUNK_SIZE)
|
|
if not data:
|
|
await asyncio.sleep(0.1)
|
|
continue
|
|
yield data
|
|
except FileNotFoundError:
|
|
raise ComputeNotFoundError(f"File '{path}' not found")
|
|
except PermissionError:
|
|
raise ComputeForbiddenError(f"File '{path}' cannot be accessed")
|
|
|
|
def get_abs_image_path(self, path, extra_dir=None):
|
|
"""
|
|
Get the absolute path of an image
|
|
|
|
:param path: file path
|
|
:param extra_dir: an additional directory to be added to the search path
|
|
|
|
:returns: file path
|
|
"""
|
|
|
|
if not path or path == ".":
|
|
return ""
|
|
orig_path = os.path.normpath(path)
|
|
|
|
img_directory = self.get_images_directory()
|
|
valid_directory_prefices = images_directories(self._NODE_TYPE)
|
|
if extra_dir:
|
|
valid_directory_prefices.append(extra_dir)
|
|
|
|
# Windows path should not be send to a unix server
|
|
if re.match(r"^[A-Z]:", path) is not None:
|
|
raise NodeError(
|
|
f"'{path}' is not allowed on this remote server (Windows path). Please only use a file from '{img_directory}'"
|
|
)
|
|
|
|
if not os.path.isabs(orig_path):
|
|
for directory in valid_directory_prefices:
|
|
log.debug(f"Searching for image '{orig_path}' in '{directory}'")
|
|
path = self._recursive_search_file_in_directory(directory, orig_path)
|
|
if path:
|
|
return force_unix_path(path)
|
|
|
|
# Not found we try the default directory
|
|
log.debug(f"Searching for image '{orig_path}' in default directory")
|
|
s = os.path.split(orig_path)
|
|
path = force_unix_path(os.path.join(img_directory, *s))
|
|
if os.path.exists(path):
|
|
return path
|
|
raise ImageMissingError(orig_path)
|
|
|
|
# Check to see if path is an absolute path to a valid directory
|
|
path = force_unix_path(path)
|
|
for directory in valid_directory_prefices:
|
|
log.debug(f"Searching for image '{orig_path}' in '{directory}'")
|
|
if os.path.commonprefix([directory, path]) == directory:
|
|
if os.path.exists(path):
|
|
return path
|
|
raise ImageMissingError(orig_path)
|
|
raise NodeError(f"'{path}' is not allowed on this remote server. Please only use a file from '{img_directory}'")
|
|
|
|
def _recursive_search_file_in_directory(self, directory, searched_file):
|
|
"""
|
|
Search for a file in directory and is subdirectories
|
|
|
|
:returns: Path or None if not found
|
|
"""
|
|
s = os.path.split(searched_file)
|
|
|
|
for root, dirs, files in os.walk(directory):
|
|
for file in files:
|
|
if s[1] == file and (s[0] == "" or root == os.path.join(directory, s[0])):
|
|
path = os.path.normpath(os.path.join(root, s[1]))
|
|
if os.path.exists(path):
|
|
return path
|
|
return None
|
|
|
|
def get_relative_image_path(self, path, extra_dir=None):
|
|
"""
|
|
Get a path relative to images directory path
|
|
or an abspath if the path is not located inside
|
|
image directory
|
|
|
|
:param path: file path
|
|
:param extra_dir: an additional directory to be added to the search path
|
|
|
|
:returns: file path
|
|
"""
|
|
|
|
if not path:
|
|
return ""
|
|
|
|
path = force_unix_path(self.get_abs_image_path(path, extra_dir))
|
|
img_directory = self.get_images_directory()
|
|
|
|
valid_directory_prefices = images_directories(self._NODE_TYPE)
|
|
if extra_dir:
|
|
valid_directory_prefices.append(extra_dir)
|
|
|
|
for directory in valid_directory_prefices:
|
|
if os.path.commonprefix([directory, path]) == directory:
|
|
relpath = os.path.relpath(path, directory)
|
|
# We don't allow to recurse search from the top image directory just for image type directory (compatibility with old releases)
|
|
if os.sep not in relpath or directory == img_directory:
|
|
return relpath
|
|
return path
|
|
|
|
async def list_images(self):
|
|
"""
|
|
Return the list of available images for this node type
|
|
|
|
:returns: Array of hash
|
|
"""
|
|
|
|
try:
|
|
return await list_images(self._NODE_TYPE)
|
|
except OSError as e:
|
|
raise ComputeError(f"Can not list images {e}")
|
|
|
|
def get_images_directory(self):
|
|
"""
|
|
Get the image directory on disk
|
|
"""
|
|
|
|
if hasattr(self, "_NODE_TYPE"):
|
|
return default_images_directory(self._NODE_TYPE)
|
|
raise NotImplementedError
|
|
|
|
async def write_image(self, filename, stream):
|
|
|
|
directory = self.get_images_directory()
|
|
path = os.path.abspath(os.path.join(directory, *os.path.split(filename)))
|
|
if os.path.commonprefix([directory, path]) != directory:
|
|
raise ComputeForbiddenError(f"Could not write image: {filename}, '{path}' is forbidden")
|
|
log.info(f"Writing image file to '{path}'")
|
|
try:
|
|
remove_checksum(path)
|
|
# We store the file under his final name only when the upload is finished
|
|
tmp_path = path + ".tmp"
|
|
os.makedirs(os.path.dirname(path), exist_ok=True)
|
|
async with aiofiles.open(tmp_path, "wb") as f:
|
|
async for chunk in stream:
|
|
await f.write(chunk)
|
|
os.chmod(tmp_path, stat.S_IWRITE | stat.S_IREAD | stat.S_IEXEC)
|
|
shutil.move(tmp_path, path)
|
|
await cancellable_wait_run_in_executor(md5sum, path)
|
|
except OSError as e:
|
|
raise ComputeError(f"Could not write image '{filename}': {e}")
|
|
|
|
def reset(self):
|
|
"""
|
|
Reset module for tests
|
|
"""
|
|
self._nodes = {}
|