""" session.py: defines the Session class used by the core-daemon daemon program that manages a CORE session. """ import logging import math import os import pwd import shutil import subprocess import sys import tempfile import threading import time from pathlib import Path from typing import Callable, Dict, List, Optional, Set, Tuple, Type, TypeVar, Union from core import constants, utils from core.configservice.manager import ConfigServiceManager from core.emane.emanemanager import EmaneManager, EmaneState from core.emane.nodes import EmaneNet from core.emulator.data import ( ConfigData, EventData, ExceptionData, FileData, InterfaceData, LinkData, LinkOptions, NodeData, ) from core.emulator.distributed import DistributedController from core.emulator.enumerations import ( EventTypes, ExceptionLevels, MessageFlags, NodeTypes, ) from core.emulator.links import CoreLink, LinkManager from core.emulator.sessionconfig import SessionConfig from core.errors import CoreError from core.location.event import EventLoop from core.location.geo import GeoLocation from core.location.mobility import BasicRangeModel, MobilityManager from core.nodes.base import CoreNode, CoreNodeBase, NodeBase, NodeOptions, Position from core.nodes.docker import DockerNode from core.nodes.interface import DEFAULT_MTU, CoreInterface from core.nodes.lxd import LxcNode from core.nodes.network import ( CtrlNet, GreTapBridge, HubNode, PtpNet, SwitchNode, TunnelNode, WlanNode, ) from core.nodes.physical import PhysicalNode, Rj45Node from core.nodes.wireless import WirelessNode from core.plugins.sdt import Sdt from core.services.coreservices import CoreServices from core.xml import corexml, corexmldeployment from core.xml.corexml import CoreXmlReader, CoreXmlWriter logger = logging.getLogger(__name__) # maps for converting from API call node type values to classes and vice versa NODES: Dict[NodeTypes, Type[NodeBase]] = { NodeTypes.DEFAULT: CoreNode, NodeTypes.PHYSICAL: PhysicalNode, NodeTypes.SWITCH: SwitchNode, NodeTypes.HUB: HubNode, NodeTypes.WIRELESS_LAN: WlanNode, NodeTypes.RJ45: Rj45Node, NodeTypes.TUNNEL: TunnelNode, NodeTypes.EMANE: EmaneNet, NodeTypes.TAP_BRIDGE: GreTapBridge, NodeTypes.PEER_TO_PEER: PtpNet, NodeTypes.CONTROL_NET: CtrlNet, NodeTypes.DOCKER: DockerNode, NodeTypes.LXC: LxcNode, NodeTypes.WIRELESS: WirelessNode, } NODES_TYPE: Dict[Type[NodeBase], NodeTypes] = {NODES[x]: x for x in NODES} CONTAINER_NODES: Set[Type[NodeBase]] = {DockerNode, LxcNode} CTRL_NET_ID: int = 9001 LINK_COLORS: List[str] = ["green", "blue", "orange", "purple", "turquoise"] NT: TypeVar = TypeVar("NT", bound=NodeBase) WIRELESS_TYPE: Tuple[Type[WlanNode], Type[EmaneNet], Type[WirelessNode]] = ( WlanNode, EmaneNet, WirelessNode, ) class Session: """ CORE session manager. """ def __init__( self, _id: int, config: Dict[str, str] = None, mkdir: bool = True ) -> None: """ Create a Session instance. :param _id: session id :param config: session configuration :param mkdir: flag to determine if a directory should be made """ self.id: int = _id # define and create session directory when desired self.directory: Path = Path(tempfile.gettempdir()) / f"pycore.{self.id}" if mkdir: self.directory.mkdir() self.name: Optional[str] = None self.file_path: Optional[Path] = None self.thumbnail: Optional[Path] = None self.user: Optional[str] = None self.event_loop: EventLoop = EventLoop() self.link_colors: Dict[int, str] = {} # dict of nodes: all nodes and nets self.nodes: Dict[int, NodeBase] = {} self.nodes_lock: threading.Lock = threading.Lock() self.link_manager: LinkManager = LinkManager() # states and hooks handlers self.state: EventTypes = EventTypes.DEFINITION_STATE self.state_time: float = time.monotonic() self.hooks: Dict[EventTypes, List[Tuple[str, str]]] = {} self.state_hooks: Dict[EventTypes, List[Callable[[EventTypes], None]]] = {} self.add_state_hook( state=EventTypes.RUNTIME_STATE, hook=self.runtime_state_hook ) # handlers for broadcasting information self.event_handlers: List[Callable[[EventData], None]] = [] self.exception_handlers: List[Callable[[ExceptionData], None]] = [] self.node_handlers: List[Callable[[NodeData], None]] = [] self.link_handlers: List[Callable[[LinkData], None]] = [] self.file_handlers: List[Callable[[FileData], None]] = [] self.config_handlers: List[Callable[[ConfigData], None]] = [] # session options/metadata self.options: SessionConfig = SessionConfig(config) self.metadata: Dict[str, str] = {} # distributed support and logic self.distributed: DistributedController = DistributedController(self) # initialize session feature helpers self.location: GeoLocation = GeoLocation() self.mobility: MobilityManager = MobilityManager(self) self.services: CoreServices = CoreServices(self) self.emane: EmaneManager = EmaneManager(self) self.sdt: Sdt = Sdt(self) # config services self.service_manager: Optional[ConfigServiceManager] = None @classmethod def get_node_class(cls, _type: NodeTypes) -> Type[NodeBase]: """ Retrieve the class for a given node type. :param _type: node type to get class for :return: node class """ node_class = NODES.get(_type) if node_class is None: raise CoreError(f"invalid node type: {_type}") return node_class @classmethod def get_node_type(cls, _class: Type[NodeBase]) -> NodeTypes: """ Retrieve node type for a given node class. :param _class: node class to get a node type for :return: node type :raises CoreError: when node type does not exist """ node_type = NODES_TYPE.get(_class) if node_type is None: raise CoreError(f"invalid node class: {_class}") return node_type def use_ovs(self) -> bool: return self.options.get_int("ovs") == 1 def linked( self, node1_id: int, node2_id: int, iface1_id: int, iface2_id: int, linked: bool ) -> None: """ Links or unlinks wired core link interfaces from being connected to the same bridge. :param node1_id: first node in link :param node2_id: second node in link :param iface1_id: node1 interface :param iface2_id: node2 interface :param linked: True if interfaces should be connected, False for disconnected :return: nothing """ node1 = self.get_node(node1_id, NodeBase) node2 = self.get_node(node2_id, NodeBase) logger.info( "link node(%s):interface(%s) node(%s):interface(%s) linked(%s)", node1.name, iface1_id, node2.name, iface2_id, linked, ) iface1 = node1.get_iface(iface1_id) iface2 = node2.get_iface(iface2_id) core_link = self.link_manager.get_link(node1, iface1, node2, iface2) if not core_link: raise CoreError( f"there is no link for node({node1.name}):interface({iface1_id}) " f"node({node2.name}):interface({iface2_id})" ) if linked: core_link.ptp.attach(iface1) core_link.ptp.attach(iface2) else: core_link.ptp.detach(iface1) core_link.ptp.detach(iface2) def add_link( self, node1_id: int, node2_id: int, iface1_data: InterfaceData = None, iface2_data: InterfaceData = None, options: LinkOptions = None, ) -> Tuple[Optional[CoreInterface], Optional[CoreInterface]]: """ Add a link between nodes. :param node1_id: node one id :param node2_id: node two id :param iface1_data: node one interface data, defaults to none :param iface2_data: node two interface data, defaults to none :param options: data for creating link, defaults to no options :return: tuple of created core interfaces, depending on link """ options = options if options else LinkOptions() # set mtu mtu = self.options.get_int("mtu") or DEFAULT_MTU if iface1_data: iface1_data.mtu = mtu if iface2_data: iface2_data.mtu = mtu node1 = self.get_node(node1_id, NodeBase) node2 = self.get_node(node2_id, NodeBase) # check for invalid linking if ( isinstance(node1, WIRELESS_TYPE) and isinstance(node2, WIRELESS_TYPE) or isinstance(node1, WIRELESS_TYPE) and not isinstance(node2, CoreNodeBase) or not isinstance(node1, CoreNodeBase) and isinstance(node2, WIRELESS_TYPE) ): raise CoreError(f"cannot link node({type(node1)}) node({type(node2)})") # custom links iface1 = None iface2 = None if isinstance(node1, (WlanNode, WirelessNode)): iface2 = self._add_wlan_link(node2, iface2_data, node1) elif isinstance(node2, (WlanNode, WirelessNode)): iface1 = self._add_wlan_link(node1, iface1_data, node2) elif isinstance(node1, EmaneNet) and isinstance(node2, CoreNode): iface2 = self._add_emane_link(node2, iface2_data, node1) elif isinstance(node2, EmaneNet) and isinstance(node1, CoreNode): iface1 = self._add_emane_link(node1, iface1_data, node2) else: iface1, iface2 = self._add_wired_link( node1, node2, iface1_data, iface2_data, options ) # configure tunnel nodes key = options.key if isinstance(node1, TunnelNode): logger.info("setting tunnel key for: %s", node1.name) node1.setkey(key, iface1_data) if isinstance(node2, TunnelNode): logger.info("setting tunnel key for: %s", node2.name) node2.setkey(key, iface2_data) self.sdt.add_link(node1_id, node2_id) return iface1, iface2 def _add_wlan_link( self, node: NodeBase, iface_data: InterfaceData, net: Union[WlanNode, WirelessNode], ) -> CoreInterface: """ Create a wlan link. :param node: node to link to wlan network :param iface_data: data to create interface with :param net: wlan network to link to :return: interface created for node """ # create interface iface = node.create_iface(iface_data) # attach to wlan net.attach(iface) # track link core_link = CoreLink(node, iface, net, None) self.link_manager.add(core_link) return iface def _add_emane_link( self, node: CoreNode, iface_data: InterfaceData, net: EmaneNet ) -> CoreInterface: """ Create am emane link. :param node: node to link to emane network :param iface_data: data to create interface with :param net: emane network to link to :return: interface created for node """ # create iface tuntap iface = net.create_tuntap(node, iface_data) # track link core_link = CoreLink(node, iface, net, None) self.link_manager.add(core_link) return iface def _add_wired_link( self, node1: NodeBase, node2: NodeBase, iface1_data: InterfaceData = None, iface2_data: InterfaceData = None, options: LinkOptions = None, ) -> Tuple[CoreInterface, CoreInterface]: """ Create a wired link between two nodes. :param node1: first node to be linked :param node2: second node to be linked :param iface1_data: data to create interface for node1 :param iface2_data: data to create interface for node2 :param options: options to configure interfaces with :return: interfaces created for both nodes """ # create interfaces iface1 = node1.create_iface(iface1_data, options) iface2 = node2.create_iface(iface2_data, options) # join and attach to ptp bridge ptp = self.create_node(PtpNet, self.state.should_start()) ptp.attach(iface1) ptp.attach(iface2) # track link core_link = CoreLink(node1, iface1, node2, iface2, ptp) self.link_manager.add(core_link) # setup link for gre tunnels if needed if ptp.up: self.distributed.create_gre_tunnels(core_link) return iface1, iface2 def delete_link( self, node1_id: int, node2_id: int, iface1_id: int = None, iface2_id: int = None ) -> None: """ Delete a link between nodes. :param node1_id: node one id :param node2_id: node two id :param iface1_id: interface id for node one :param iface2_id: interface id for node two :return: nothing :raises core.CoreError: when no common network is found for link being deleted """ node1 = self.get_node(node1_id, NodeBase) node2 = self.get_node(node2_id, NodeBase) logger.info( "deleting link node(%s):interface(%s) node(%s):interface(%s)", node1.name, iface1_id, node2.name, iface2_id, ) iface1 = None iface2 = None if isinstance(node1, (WlanNode, WirelessNode)): iface2 = node2.delete_iface(iface2_id) node1.detach(iface2) elif isinstance(node2, (WlanNode, WirelessNode)): iface1 = node1.delete_iface(iface1_id) node2.detach(iface1) elif isinstance(node1, EmaneNet): iface2 = node2.delete_iface(iface2_id) node1.detach(iface2) elif isinstance(node2, EmaneNet): iface1 = node1.delete_iface(iface1_id) node2.detach(iface1) else: iface1 = node1.delete_iface(iface1_id) iface2 = node2.delete_iface(iface2_id) core_link = self.link_manager.delete(node1, iface1, node2, iface2) if core_link.ptp: self.delete_node(core_link.ptp.id) self.sdt.delete_link(node1_id, node2_id) def update_link( self, node1_id: int, node2_id: int, iface1_id: int = None, iface2_id: int = None, options: LinkOptions = None, ) -> None: """ Update link information between nodes. :param node1_id: node one id :param node2_id: node two id :param iface1_id: interface id for node one :param iface2_id: interface id for node two :param options: data to update link with :return: nothing :raises core.CoreError: when updating a wireless type link, when there is a unknown link between networks """ if not options: options = LinkOptions() node1 = self.get_node(node1_id, NodeBase) node2 = self.get_node(node2_id, NodeBase) logger.info( "update link node(%s):interface(%s) node(%s):interface(%s)", node1.name, iface1_id, node2.name, iface2_id, ) iface1 = node1.get_iface(iface1_id) if iface1_id is not None else None iface2 = node2.get_iface(iface2_id) if iface2_id is not None else None core_link = self.link_manager.get_link(node1, iface1, node2, iface2) if not core_link: raise CoreError( f"there is no link for node({node1.name}):interface({iface1_id}) " f"node({node2.name}):interface({iface2_id})" ) if iface1: iface1.options.update(options) iface1.set_config() if iface2 and not options.unidirectional: iface2.options.update(options) iface2.set_config() def next_node_id(self) -> int: """ Find the next valid node id, starting from 1. :return: next node id """ _id = 1 while True: if _id not in self.nodes: break _id += 1 return _id def add_node( self, _class: Type[NT], _id: int = None, name: str = None, server: str = None, position: Position = None, options: NodeOptions = None, ) -> NT: """ Add a node to the session, based on the provided node data. :param _class: node class to create :param _id: id for node, defaults to None for generated id :param name: name to assign to node :param server: distributed server for node, if desired :param position: geo or x/y/z position to set :param options: options to create node with :return: created node :raises core.CoreError: when an invalid node type is given """ # set node start based on current session state, override and check when rj45 start = self.state.should_start() enable_rj45 = self.options.get_int("enablerj45") == 1 if _class == Rj45Node and not enable_rj45: start = False # generate options if not provided options = options if options else _class.create_options() # verify distributed server dist_server = None if server is not None: dist_server = self.distributed.servers.get(server) if not dist_server: raise CoreError(f"invalid distributed server: {server}") # create node node = self.create_node(_class, start, _id, name, dist_server, options) # set node position position = position or Position() if position.has_geo(): self.set_node_geo(node, position.lon, position.lat, position.alt) else: self.set_node_pos(node, position.x, position.y) # setup default wlan if isinstance(node, WlanNode): self.mobility.set_model_config(self.id, BasicRangeModel.name) # boot core nodes after runtime is_runtime = self.state == EventTypes.RUNTIME_STATE if is_runtime and isinstance(node, CoreNode): self.add_remove_control_iface(node, remove=False) self.boot_node(node) self.sdt.add_node(node) return node def set_node_pos(self, node: NodeBase, x: float, y: float) -> None: node.setposition(x, y, None) self.sdt.edit_node( node, node.position.lon, node.position.lat, node.position.alt ) def set_node_geo(self, node: NodeBase, lon: float, lat: float, alt: float) -> None: x, y, _ = self.location.getxyz(lat, lon, alt) if math.isinf(x) or math.isinf(y): raise CoreError( f"invalid geo for current reference/scale: {lon},{lat},{alt}" ) node.setposition(x, y, None) node.position.set_geo(lon, lat, alt) self.sdt.edit_node(node, lon, lat, alt) def open_xml(self, file_path: Path, start: bool = False) -> None: """ Import a session from the EmulationScript XML format. :param file_path: xml file to load session from :param start: instantiate session if true, false otherwise :return: nothing """ logger.info("opening xml: %s", file_path) # clear out existing session self.clear() # set state and read xml state = EventTypes.CONFIGURATION_STATE if start else EventTypes.DEFINITION_STATE self.set_state(state) self.name = file_path.name self.file_path = file_path CoreXmlReader(self).read(file_path) # start session if needed if start: self.set_state(EventTypes.INSTANTIATION_STATE) self.instantiate() def save_xml(self, file_path: Path) -> None: """ Export a session to the EmulationScript XML format. :param file_path: file name to write session xml to :return: nothing """ CoreXmlWriter(self).write(file_path) def add_hook( self, state: EventTypes, file_name: str, data: str, src_name: str = None ) -> None: """ Store a hook from a received file message. :param state: when to run hook :param file_name: file name for hook :param data: hook data :param src_name: source name :return: nothing """ logger.info( "setting state hook: %s - %s source(%s)", state, file_name, src_name ) hook = file_name, data state_hooks = self.hooks.setdefault(state, []) state_hooks.append(hook) # immediately run a hook if it is in the current state if self.state == state: logger.info("immediately running new state hook") self.run_hook(hook) def clear(self) -> None: """ Clear all CORE session data. (nodes, hooks, etc) :return: nothing """ self.emane.shutdown() self.delete_nodes() self.link_manager.reset() self.distributed.shutdown() self.hooks.clear() self.emane.reset() self.emane.config_reset() self.location.reset() self.services.reset() self.mobility.config_reset() self.link_colors.clear() def set_location(self, lat: float, lon: float, alt: float, scale: float) -> None: """ Set session geospatial location. :param lat: latitude :param lon: longitude :param alt: altitude :param scale: reference scale :return: nothing """ self.location.setrefgeo(lat, lon, alt) self.location.refscale = scale def shutdown(self) -> None: """ Shutdown all session nodes and remove the session directory. """ if self.state == EventTypes.SHUTDOWN_STATE: logger.info("session(%s) state(%s) already shutdown", self.id, self.state) else: logger.info("session(%s) state(%s) shutting down", self.id, self.state) self.set_state(EventTypes.SHUTDOWN_STATE, send_event=True) # clear out current core session self.clear() # shutdown sdt self.sdt.shutdown() # remove this sessions working directory preserve = self.options.get_int("preservedir") == 1 if not preserve: shutil.rmtree(self.directory, ignore_errors=True) def broadcast_event(self, event_data: EventData) -> None: """ Handle event data that should be provided to event handler. :param event_data: event data to send out :return: nothing """ for handler in self.event_handlers: handler(event_data) def broadcast_exception(self, exception_data: ExceptionData) -> None: """ Handle exception data that should be provided to exception handlers. :param exception_data: exception data to send out :return: nothing """ for handler in self.exception_handlers: handler(exception_data) def broadcast_node( self, node: NodeBase, message_type: MessageFlags = MessageFlags.NONE, source: str = None, ) -> None: """ Handle node data that should be provided to node handlers. :param node: node to broadcast :param message_type: type of message to broadcast, None by default :param source: source of broadcast, None by default :return: nothing """ node_data = NodeData(node=node, message_type=message_type, source=source) for handler in self.node_handlers: handler(node_data) def broadcast_file(self, file_data: FileData) -> None: """ Handle file data that should be provided to file handlers. :param file_data: file data to send out :return: nothing """ for handler in self.file_handlers: handler(file_data) def broadcast_config(self, config_data: ConfigData) -> None: """ Handle config data that should be provided to config handlers. :param config_data: config data to send out :return: nothing """ for handler in self.config_handlers: handler(config_data) def broadcast_link(self, link_data: LinkData) -> None: """ Handle link data that should be provided to link handlers. :param link_data: link data to send out :return: nothing """ for handler in self.link_handlers: handler(link_data) def set_state(self, state: EventTypes, send_event: bool = False) -> None: """ Set the session's current state. :param state: state to set to :param send_event: if true, generate core API event messages :return: nothing """ if self.state == state: return self.state = state self.state_time = time.monotonic() logger.info("changing session(%s) to state %s", self.id, state.name) self.run_hooks(state) self.run_state_hooks(state) if send_event: event_data = EventData(event_type=state, time=str(time.monotonic())) self.broadcast_event(event_data) def run_hooks(self, state: EventTypes) -> None: """ Run hook scripts upon changing states. If hooks is not specified, run all hooks in the given state. :param state: state to run hooks for :return: nothing """ hooks = self.hooks.get(state, []) for hook in hooks: self.run_hook(hook) def run_hook(self, hook: Tuple[str, str]) -> None: """ Run a hook. :param hook: hook to run :return: nothing """ file_name, data = hook logger.info("running hook %s", file_name) file_path = self.directory / file_name log_path = self.directory / f"{file_name}.log" try: with file_path.open("w") as f: f.write(data) with log_path.open("w") as f: args = ["/bin/sh", file_name] subprocess.check_call( args, stdout=f, stderr=subprocess.STDOUT, close_fds=True, cwd=self.directory, env=self.get_environment(), ) except (IOError, subprocess.CalledProcessError): logger.exception("error running hook: %s", file_path) def run_state_hooks(self, state: EventTypes) -> None: """ Run state hooks. :param state: state to run hooks for :return: nothing """ for hook in self.state_hooks.get(state, []): self.run_state_hook(state, hook) def run_state_hook(self, state: EventTypes, hook: Callable[[EventTypes], None]): try: hook(state) except Exception: message = f"exception occurred when running {state.name} state hook: {hook}" logger.exception(message) self.exception(ExceptionLevels.ERROR, "Session.run_state_hooks", message) def add_state_hook( self, state: EventTypes, hook: Callable[[EventTypes], None] ) -> None: """ Add a state hook. :param state: state to add hook for :param hook: hook callback for the state :return: nothing """ hooks = self.state_hooks.setdefault(state, []) if hook in hooks: raise CoreError("attempting to add duplicate state hook") hooks.append(hook) if self.state == state: self.run_state_hook(state, hook) def del_state_hook( self, state: EventTypes, hook: Callable[[EventTypes], None] ) -> None: """ Delete a state hook. :param state: state to delete hook for :param hook: hook to delete :return: nothing """ hooks = self.state_hooks.get(state, []) if hook in hooks: hooks.remove(hook) def runtime_state_hook(self, _state: EventTypes) -> None: """ Runtime state hook check. :param _state: state to check :return: nothing """ self.emane.poststartup() # create session deployed xml xml_writer = corexml.CoreXmlWriter(self) corexmldeployment.CoreXmlDeployment(self, xml_writer.scenario) xml_file_path = self.directory / "session-deployed.xml" xml_writer.write(xml_file_path) def get_environment(self, state: bool = True) -> Dict[str, str]: """ Get an environment suitable for a subprocess.Popen call. This is the current process environment with some session-specific variables. :param state: flag to determine if session state should be included :return: environment variables """ env = os.environ.copy() env["CORE_PYTHON"] = sys.executable env["SESSION"] = str(self.id) env["SESSION_SHORT"] = self.short_session_id() env["SESSION_DIR"] = str(self.directory) env["SESSION_NAME"] = str(self.name) env["SESSION_FILENAME"] = str(self.file_path) env["SESSION_USER"] = str(self.user) if state: env["SESSION_STATE"] = str(self.state) # try reading and merging optional environments from: # /etc/core/environment # /home/user/.coregui/environment # /tmp/pycore./environment core_env_path = constants.CORE_CONF_DIR / "environment" session_env_path = self.directory / "environment" if self.user: user_home_path = Path(f"~{self.user}").expanduser() user_env = user_home_path / ".coregui" / "environment" paths = [core_env_path, user_env, session_env_path] else: paths = [core_env_path, session_env_path] for path in paths: if path.is_file(): try: utils.load_config(path, env) except IOError: logger.exception("error reading environment file: %s", path) return env def set_user(self, user: str) -> None: """ Set the username for this session. Update the permissions of the session dir to allow the user write access. :param user: user to give write permissions to for the session directory :return: nothing """ self.user = user try: uid = pwd.getpwnam(user).pw_uid gid = self.directory.stat().st_gid os.chown(self.directory, uid, gid) except IOError: logger.exception("failed to set permission on %s", self.directory) def create_node( self, _class: Type[NT], start: bool, _id: int = None, name: str = None, server: str = None, options: NodeOptions = None, ) -> NT: """ Create an emulation node. :param _class: node class to create :param start: True to start node, False otherwise :param _id: id for node, defaults to None for generated id :param name: name to assign to node :param server: distributed server for node, if desired :param options: options to create node with :return: the created node instance :raises core.CoreError: when id of the node to create already exists """ with self.nodes_lock: node = _class(self, _id=_id, name=name, server=server, options=options) if node.id in self.nodes: node.shutdown() raise CoreError(f"duplicate node id {node.id} for {node.name}") self.nodes[node.id] = node logger.info( "created node(%s) id(%s) name(%s) start(%s)", _class.__name__, node.id, node.name, start, ) if start: node.startup() return node def get_node(self, _id: int, _class: Type[NT]) -> NT: """ Get a session node. :param _id: node id to retrieve :param _class: expected node class :return: node for the given id :raises core.CoreError: when node does not exist """ node = self.nodes.get(_id) if node is None: raise CoreError(f"unknown node id {_id}") if not isinstance(node, _class): actual = node.__class__.__name__ expected = _class.__name__ raise CoreError(f"node class({actual}) is not expected({expected})") return node def delete_node(self, _id: int) -> bool: """ Delete a node from the session and check if session should shutdown, if no nodes are left. :param _id: id of node to delete :return: True if node deleted, False otherwise """ # delete node and check for session shutdown if a node was removed node = None with self.nodes_lock: if _id in self.nodes: node = self.nodes.pop(_id) logger.info("deleted node(%s)", node.name) if node: node.shutdown() self.sdt.delete_node(_id) return node is not None def delete_nodes(self) -> None: """ Clear the nodes dictionary, and call shutdown for each node. """ nodes_ids = [] with self.nodes_lock: funcs = [] while self.nodes: _, node = self.nodes.popitem() nodes_ids.append(node.id) funcs.append((node.shutdown, [], {})) utils.threadpool(funcs) for node_id in nodes_ids: self.sdt.delete_node(node_id) def exception( self, level: ExceptionLevels, source: str, text: str, node_id: int = None ) -> None: """ Generate and broadcast an exception event. :param level: exception level :param source: source name :param text: exception message :param node_id: node related to exception :return: nothing """ exception_data = ExceptionData( node=node_id, session=self.id, level=level, source=source, date=time.ctime(), text=text, ) self.broadcast_exception(exception_data) def instantiate(self) -> List[Exception]: """ We have entered the instantiation state, invoke startup methods of various managers and boot the nodes. Validate nodes and check for transition to the runtime state. :return: list of service boot errors during startup """ if self.state == EventTypes.RUNTIME_STATE: logger.warning("ignoring instantiate, already in runtime state") return [] # create control net interfaces and network tunnels # which need to exist for emane to sync on location events # in distributed scenarios self.add_remove_control_net(0, remove=False) # initialize distributed tunnels self.distributed.start() # instantiate will be invoked again upon emane configure if self.emane.startup() == EmaneState.NOT_READY: return [] # boot node services and then start mobility exceptions = self.boot_nodes() if not exceptions: # complete wireless node for node in self.nodes.values(): if isinstance(node, WirelessNode): node.post_startup() self.mobility.startup() # notify listeners that instantiation is complete event = EventData(event_type=EventTypes.INSTANTIATION_COMPLETE) self.broadcast_event(event) # startup event loop self.event_loop.run() self.set_state(EventTypes.RUNTIME_STATE, send_event=True) return exceptions def get_node_count(self) -> int: """ Returns the number of CoreNodes and CoreNets, except for those that are not considered in the GUI's node count. :return: created node count """ with self.nodes_lock: count = 0 for node in self.nodes.values(): is_p2p_ctrlnet = isinstance(node, (PtpNet, CtrlNet)) is_tap = isinstance(node, GreTapBridge) and not isinstance( node, TunnelNode ) if is_p2p_ctrlnet or is_tap: continue count += 1 return count def data_collect(self) -> None: """ Tear down a running session. Stop the event loop and any running nodes, and perform clean-up. :return: nothing """ if self.state.already_collected(): logger.info( "session(%s) state(%s) already data collected", self.id, self.state ) return logger.info("session(%s) state(%s) data collection", self.id, self.state) self.set_state(EventTypes.DATACOLLECT_STATE, send_event=True) # stop event loop self.event_loop.stop() # stop mobility and node services with self.nodes_lock: funcs = [] for node in self.nodes.values(): if isinstance(node, CoreNodeBase) and node.up: args = (node,) funcs.append((self.services.stop_services, args, {})) utils.threadpool(funcs) # shutdown emane self.emane.shutdown() # update control interface hosts self.update_control_iface_hosts(remove=True) # remove all four possible control networks for i in range(4): self.add_remove_control_net(i, remove=True) def short_session_id(self) -> str: """ Return a shorter version of the session ID, appropriate for interface names, where length may be limited. :return: short session id """ ssid = (self.id >> 8) ^ (self.id & ((1 << 8) - 1)) return f"{ssid:x}" def boot_node(self, node: CoreNode) -> None: """ Boot node by adding a control interface when necessary and starting node services. :param node: node to boot :return: nothing """ logger.info("booting node(%s): %s", node.name, [x.name for x in node.services]) self.services.boot_services(node) node.start_config_services() def boot_nodes(self) -> List[Exception]: """ Invoke the boot() procedure for all nodes and send back node messages to the GUI for node messages that had the status request flag. :return: service boot exceptions """ with self.nodes_lock: funcs = [] start = time.monotonic() for node in self.nodes.values(): if isinstance(node, CoreNode): self.add_remove_control_iface(node, remove=False) funcs.append((self.boot_node, (node,), {})) results, exceptions = utils.threadpool(funcs) total = time.monotonic() - start logger.debug("boot run time: %s", total) if not exceptions: self.update_control_iface_hosts() return exceptions def get_control_net_prefixes(self) -> List[str]: """ Retrieve control net prefixes. :return: control net prefix list """ p = self.options.get("controlnet") p0 = self.options.get("controlnet0") p1 = self.options.get("controlnet1") p2 = self.options.get("controlnet2") p3 = self.options.get("controlnet3") if not p0 and p: p0 = p return [p0, p1, p2, p3] def get_control_net_server_ifaces(self) -> List[str]: """ Retrieve control net server interfaces. :return: list of control net server interfaces """ d0 = self.options.get("controlnetif0") if d0: logger.error("controlnet0 cannot be assigned with a host interface") d1 = self.options.get("controlnetif1") d2 = self.options.get("controlnetif2") d3 = self.options.get("controlnetif3") return [None, d1, d2, d3] def get_control_net_index(self, dev: str) -> int: """ Retrieve control net index. :param dev: device to get control net index for :return: control net index, -1 otherwise """ if dev[0:4] == "ctrl" and int(dev[4]) in [0, 1, 2, 3]: index = int(dev[4]) if index == 0: return index if index < 4 and self.get_control_net_prefixes()[index] is not None: return index return -1 def get_control_net(self, net_index: int) -> CtrlNet: """ Retrieve a control net based on index. :param net_index: control net index :return: control net :raises CoreError: when control net is not found """ return self.get_node(CTRL_NET_ID + net_index, CtrlNet) def add_remove_control_net( self, net_index: int, remove: bool = False, conf_required: bool = True ) -> Optional[CtrlNet]: """ Create a control network bridge as necessary. When the remove flag is True, remove the bridge that connects control interfaces. The conf_reqd flag, when False, causes a control network bridge to be added even if one has not been configured. :param net_index: network index :param remove: flag to check if it should be removed :param conf_required: flag to check if conf is required :return: control net node """ logger.debug( "add/remove control net: index(%s) remove(%s) conf_required(%s)", net_index, remove, conf_required, ) prefix_spec_list = self.get_control_net_prefixes() prefix_spec = prefix_spec_list[net_index] if not prefix_spec: if conf_required: # no controlnet needed return None else: prefix_spec = CtrlNet.DEFAULT_PREFIX_LIST[net_index] logger.debug("prefix spec: %s", prefix_spec) server_iface = self.get_control_net_server_ifaces()[net_index] # return any existing controlnet bridge try: control_net = self.get_control_net(net_index) if remove: self.delete_node(control_net.id) return None return control_net except CoreError: if remove: return None # build a new controlnet bridge _id = CTRL_NET_ID + net_index # use the updown script for control net 0 only. updown_script = None if net_index == 0: updown_script = self.options.get("controlnet_updown_script") or None if not updown_script: logger.debug("controlnet updown script not configured") prefixes = prefix_spec.split() if len(prefixes) > 1: # a list of per-host prefixes is provided try: # split first (master) entry into server and prefix prefix = prefixes[0].split(":", 1)[1] except IndexError: # no server name. possibly only one server prefix = prefixes[0] else: prefix = prefixes[0] logger.info( "controlnet(%s) prefix(%s) updown(%s) serverintf(%s)", _id, prefix, updown_script, server_iface, ) options = CtrlNet.create_options() options.prefix = prefix options.updown_script = updown_script options.serverintf = server_iface control_net = self.create_node(CtrlNet, False, _id, options=options) control_net.brname = f"ctrl{net_index}.{self.short_session_id()}" control_net.startup() return control_net def add_remove_control_iface( self, node: CoreNode, net_index: int = 0, remove: bool = False, conf_required: bool = True, ) -> None: """ Add a control interface to a node when a 'controlnet' prefix is listed in the config file or session options. Uses addremovectrlnet() to build or remove the control bridge. If conf_reqd is False, the control network may be built even when the user has not configured one (e.g. for EMANE.) :param node: node to add or remove control interface :param net_index: network index :param remove: flag to check if it should be removed :param conf_required: flag to check if conf is required :return: nothing """ control_net = self.add_remove_control_net(net_index, remove, conf_required) if not control_net: return if not node: return # ctrl# already exists if node.ifaces.get(control_net.CTRLIF_IDX_BASE + net_index): return try: ip4 = control_net.prefix[node.id] ip4_mask = control_net.prefix.prefixlen iface_data = InterfaceData( id=control_net.CTRLIF_IDX_BASE + net_index, name=f"ctrl{net_index}", mac=utils.random_mac(), ip4=ip4, ip4_mask=ip4_mask, mtu=DEFAULT_MTU, ) iface = node.create_iface(iface_data) control_net.attach(iface) iface.control = True except ValueError: msg = f"Control interface not added to node {node.id}. " msg += f"Invalid control network prefix ({control_net.prefix}). " msg += "A longer prefix length may be required for this many nodes." logger.exception(msg) def update_control_iface_hosts( self, net_index: int = 0, remove: bool = False ) -> None: """ Add the IP addresses of control interfaces to the /etc/hosts file. :param net_index: network index to update :param remove: flag to check if it should be removed :return: nothing """ if not self.options.get_bool("update_etc_hosts", False): return try: control_net = self.get_control_net(net_index) except CoreError: logger.exception("error retrieving control net node") return header = f"CORE session {self.id} host entries" if remove: logger.info("Removing /etc/hosts file entries.") utils.file_demunge("/etc/hosts", header) return entries = [] for iface in control_net.get_ifaces(): name = iface.node.name for ip in iface.ips(): entries.append(f"{ip.ip} {name}") logger.info("Adding %d /etc/hosts file entries.", len(entries)) utils.file_munge("/etc/hosts", header, "\n".join(entries) + "\n") def runtime(self) -> float: """ Return the current time we have been in the runtime state, or zero if not in runtime. """ if self.state == EventTypes.RUNTIME_STATE: return time.monotonic() - self.state_time else: return 0.0 def add_event( self, event_time: float, node_id: int = None, name: str = None, data: str = None ) -> None: """ Add an event to the event queue, with a start time relative to the start of the runtime state. :param event_time: event time :param node_id: node to add event for :param name: name of event :param data: data for event :return: nothing """ current_time = self.runtime() if current_time > 0: if event_time <= current_time: logger.warning( "could not schedule past event for time %s (run time is now %s)", event_time, current_time, ) return event_time = event_time - current_time self.event_loop.add_event( event_time, self.run_event, node_id=node_id, name=name, data=data ) if not name: name = "" logger.info( "scheduled event %s at time %s data=%s", name, event_time + current_time, data, ) def run_event( self, node_id: int = None, name: str = None, data: str = None ) -> None: """ Run a scheduled event, executing commands in the data string. :param node_id: node id to run event :param name: event name :param data: event data :return: nothing """ if data is None: logger.warning("no data for event node(%s) name(%s)", node_id, name) return now = self.runtime() if not name: name = "" logger.info("running event %s at time %s cmd=%s", name, now, data) if not node_id: utils.mute_detach(data) else: node = self.get_node(node_id, CoreNodeBase) node.cmd(data, wait=False) def get_link_color(self, network_id: int) -> str: """ Assign a color for links associated with a network. :param network_id: network to get a link color for :return: link color """ color = self.link_colors.get(network_id) if not color: index = len(self.link_colors) % len(LINK_COLORS) color = LINK_COLORS[index] self.link_colors[network_id] = color return color