diff --git a/.gitignore b/.gitignore index 49bac9bd..7499c81b 100644 --- a/.gitignore +++ b/.gitignore @@ -34,3 +34,6 @@ nosetests.xml .project .pydevproject .settings + +# Gedit Backup Files +*~ diff --git a/gns3server/modules/__init__.py b/gns3server/modules/__init__.py index 59304d19..7fd76401 100644 --- a/gns3server/modules/__init__.py +++ b/gns3server/modules/__init__.py @@ -18,8 +18,10 @@ import sys from .base import IModule from .dynamips import Dynamips +from .vpcs import VPCS MODULES = [Dynamips] +MODULES.append(VPCS) if sys.platform.startswith("linux"): # IOU runs only on Linux diff --git a/gns3server/modules/vpcs/__init__.py b/gns3server/modules/vpcs/__init__.py new file mode 100644 index 00000000..a1060b71 --- /dev/null +++ b/gns3server/modules/vpcs/__init__.py @@ -0,0 +1,675 @@ +# -*- coding: utf-8 -*- +# +# Copyright (C) 2014 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 . + +""" +vpcs server module. +""" + +import os +import sys +import base64 +import tempfile +import struct +import socket +import shutil + +from gns3server.modules import IModule +from gns3server.config import Config +import gns3server.jsonrpc as jsonrpc +from .vpcs_device import VPCSDevice +from .vpcs_error import VPCSError +from .nios.nio_udp import NIO_UDP +from .nios.nio_tap import NIO_TAP +from ..attic import find_unused_port + +from .schemas import VPCS_CREATE_SCHEMA +from .schemas import VPCS_DELETE_SCHEMA +from .schemas import VPCS_UPDATE_SCHEMA +from .schemas import VPCS_START_SCHEMA +from .schemas import VPCS_STOP_SCHEMA +from .schemas import VPCS_RELOAD_SCHEMA +from .schemas import VPCS_ALLOCATE_UDP_PORT_SCHEMA +from .schemas import VPCS_ADD_NIO_SCHEMA +from .schemas import VPCS_DELETE_NIO_SCHEMA + +import logging +log = logging.getLogger(__name__) + + +class VPCS(IModule): + """ + vpcs module. + + :param name: module name + :param args: arguments for the module + :param kwargs: named arguments for the module + """ + + def __init__(self, name, *args, **kwargs): + + # get the vpcs location + config = Config.instance() + vpcs_config = config.get_section_config(name.upper()) + self._vpcs = vpcs_config.get("vpcs") + if not self._vpcs or not os.path.isfile(self._vpcs): + vpcs_in_cwd = os.path.join(os.getcwd(), "vpcs") + if os.path.isfile(vpcs_in_cwd): + self._vpcs = vpcs_in_cwd + else: + # look for vpcs if none is defined or accessible + for path in os.environ["PATH"].split(":"): + try: + if "vpcs" in os.listdir(path) and os.access(os.path.join(path, "vpcs"), os.X_OK): + self._vpcs = os.path.join(path, "vpcs") + break + except OSError: + continue + + if not self._vpcs: + log.warning("vpcs binary couldn't be found!") + elif not os.access(self._vpcs, os.X_OK): + log.warning("vpcs is not executable") + + # a new process start when calling IModule + IModule.__init__(self, name, *args, **kwargs) + self._vpcs_instances = {} + self._console_start_port_range = 4001 + self._console_end_port_range = 4512 + self._allocated_console_ports = [] + self._current_console_port = self._console_start_port_range + self._udp_start_port_range = 30001 + self._udp_end_port_range = 40001 + self._current_udp_port = self._udp_start_port_range + self._host = kwargs["host"] + self._projects_dir = kwargs["projects_dir"] + self._tempdir = kwargs["temp_dir"] + self._working_dir = self._projects_dir + + # check every 5 seconds + #self._vpcs_callback = self.add_periodic_callback(self._check_vpcs_is_alive, 5000) + #self._vpcs_callback.start() + + def stop(self, signum=None): + """ + Properly stops the module. + + :param signum: signal number (if called by the signal handler) + """ + + self._vpcs_callback.stop() + # delete all vpcs instances + for vpcs_id in self._vpcs_instances: + vpcs_instance = self._vpcs_instances[vpcs_id] + vpcs_instance.delete() + + IModule.stop(self, signum) # this will stop the I/O loop + + def _check_vpcs_is_alive(self): + """ + Periodic callback to check if vpcs and vpcs are alive + for each vpcs instance. + + Sends a notification to the client if not. + """ + + for vpcs_id in self._vpcs_instances: + vpcs_instance = self._vpcs_instances[vpcs_id] + if vpcs_instance.started and (not vpcs_instance.is_running() or not vpcs_instance.is_vpcs_running()): + notification = {"module": self.name, + "id": vpcs_id, + "name": vpcs_instance.name} + if not vpcs_instance.is_running(): + stdout = vpcs_instance.read_vpcs_stdout() + notification["message"] = "vpcs has stopped running" + notification["details"] = stdout + self.send_notification("{}.vpcs_stopped".format(self.name), notification) + vpcs_instance.stop() + + def get_vpcs_instance(self, vpcs_id): + """ + Returns an vpcs device instance. + + :param vpcs_id: vpcs device identifier + + :returns: vpcsDevice instance + """ + + if vpcs_id not in self._vpcs_instances: + log.debug("vpcs device ID {} doesn't exist".format(vpcs_id), exc_info=1) + self.send_custom_error("vpcs device ID {} doesn't exist".format(vpcs_id)) + return None + return self._vpcs_instances[vpcs_id] + + @IModule.route("vpcs.reset") + def reset(self, request): + """ + Resets the module. + + :param request: JSON request + """ + + # delete all vpcs instances + for vpcs_id in self._vpcs_instances: + vpcs_instance = self._vpcs_instances[vpcs_id] + vpcs_instance.delete() + + # resets the instance IDs + VPCSDevice.reset() + + self._vpcs_instances.clear() + self._remote_server = False + self._current_console_port = self._console_start_port_range + self._current_udp_port = self._udp_start_port_range + + log.info("vpcs module has been reset") + + @IModule.route("vpcs.settings") + def settings(self, request): + """ + Set or update settings. + + Optional request parameters: + - working_dir (path to a working directory) + - project_name + - console_start_port_range + - console_end_port_range + - udp_start_port_range + - udp_end_port_range + + :param request: JSON request + """ + + if request == None: + self.send_param_error() + return + + if "vpcs" in request and request["vpcs"]: + self._vpcs = request["vpcs"] + log.info("vpcs path set to {}".format(self._vpcs)) + + if "working_dir" in request: + new_working_dir = request["working_dir"] + log.info("this server is local with working directory path to {}".format(new_working_dir)) + else: + new_working_dir = os.path.join(self._projects_dir, request["project_name"] + ".gns3") + log.info("this server is remote with working directory path to {}".format(new_working_dir)) + if self._projects_dir != self._working_dir != new_working_dir: + if not os.path.isdir(new_working_dir): + try: + shutil.move(self._working_dir, new_working_dir) + except OSError as e: + log.error("could not move working directory from {} to {}: {}".format(self._working_dir, + new_working_dir, + e)) + return + + # update the working directory if it has changed + if self._working_dir != new_working_dir: + self._working_dir = new_working_dir + for vpcs_id in self._vpcs_instances: + vpcs_instance = self._vpcs_instances[vpcs_id] + vpcs_instance.working_dir = self._working_dir + + if "console_start_port_range" in request and "console_end_port_range" in request: + self._console_start_port_range = request["console_start_port_range"] + self._console_end_port_range = request["console_end_port_range"] + + if "udp_start_port_range" in request and "udp_end_port_range" in request: + self._udp_start_port_range = request["udp_start_port_range"] + self._udp_end_port_range = request["udp_end_port_range"] + + log.debug("received request {}".format(request)) + + def test_result(self, message, result="error"): + """ + """ + + return {"result": result, "message": message} + + @IModule.route("vpcs.test_settings") + def test_settings(self, request): + """ + """ + + response = [] + + self.send_response(response) + + @IModule.route("vpcs.create") + def vpcs_create(self, request): + """ + Creates a new vpcs instance. + + Mandatory request parameters: + - path (path to the vpcs executable) + + Optional request parameters: + - name (vpcs name) + + Response parameters: + - id (vpcs instance identifier) + - name (vpcs name) + - default settings + + :param request: JSON request + """ + + # validate the request + if not self.validate_request(request, VPCS_CREATE_SCHEMA): + return + + name = None + if "name" in request: + name = request["name"] + vpcs_path = request["path"] + + try: + try: + os.makedirs(self._working_dir) + except FileExistsError: + pass + except OSError as e: + raise vpcsError("Could not create working directory {}".format(e)) + + vpcs_instance = VPCSDevice(vpcs_path, self._working_dir, host=self._host, name=name) + # find a console port + if self._current_console_port > self._console_end_port_range: + self._current_console_port = self._console_start_port_range + try: + vpcs_instance.console = find_unused_port(self._current_console_port, self._console_end_port_range, self._host) + except Exception as e: + raise vpcsError(e) + self._current_console_port += 1 + except VPCSError as e: + self.send_custom_error(str(e)) + return + + response = {"name": vpcs_instance.name, + "id": vpcs_instance.id} + + defaults = vpcs_instance.defaults() + response.update(defaults) + self._vpcs_instances[vpcs_instance.id] = vpcs_instance + self.send_response(response) + + @IModule.route("vpcs.delete") + def vpcs_delete(self, request): + """ + Deletes an vpcs instance. + + Mandatory request parameters: + - id (vpcs instance identifier) + + Response parameter: + - True on success + + :param request: JSON request + """ + + # validate the request + if not self.validate_request(request, VPCS_DELETE_SCHEMA): + return + + # get the instance + vpcs_instance = self.get_vpcs_instance(request["id"]) + if not vpcs_instance: + return + + try: + vpcs_instance.delete() + del self._vpcs_instances[request["id"]] + except vpcsError as e: + self.send_custom_error(str(e)) + return + + self.send_response(True) + + @IModule.route("vpcs.update") + def vpcs_update(self, request): + """ + Updates an vpcs instance + + Mandatory request parameters: + - id (vpcs instance identifier) + + Optional request parameters: + - any setting to update + - script_file_base64 (script-file base64 encoded) + + Response parameters: + - updated settings + + :param request: JSON request + """ + + # validate the request + if not self.validate_request(request, VPCS_UPDATE_SCHEMA): + return + + # get the instance + vpcs_instance = self.get_vpcs_instance(request["id"]) + if not vpcs_instance: + return + + response = {} + try: + # a new script-file has been pushed + if "script_file_base64" in request: + config = base64.decodestring(request["script_file_base64"].encode("utf-8")).decode("utf-8") + config = "!\n" + config.replace("\r", "") + config = config.replace('%h', vpcs_instance.name) + config_path = os.path.join(vpcs_instance.working_dir, "script-file") + try: + with open(config_path, "w") as f: + log.info("saving script-file to {}".format(config_path)) + f.write(config) + except OSError as e: + raise vpcsError("Could not save the configuration {}: {}".format(config_path, e)) + # update the request with the new local script-file path + request["script_file"] = os.path.basename(config_path) + + except vpcsError as e: + self.send_custom_error(str(e)) + return + + # update the vpcs settings + for name, value in request.items(): + if hasattr(vpcs_instance, name) and getattr(vpcs_instance, name) != value: + try: + setattr(vpcs_instance, name, value) + response[name] = value + except vpcsError as e: + self.send_custom_error(str(e)) + return + + self.send_response(response) + + @IModule.route("vpcs.start") + def vm_start(self, request): + """ + Starts an vpcs instance. + + Mandatory request parameters: + - id (vpcs instance identifier) + + Response parameters: + - True on success + + :param request: JSON request + """ + + # validate the request + if not self.validate_request(request, VPCS_START_SCHEMA): + return + + # get the instance + vpcs_instance = self.get_vpcs_instance(request["id"]) + if not vpcs_instance: + return + + try: + log.debug("starting vpcs with command: {}".format(vpcs_instance.command())) + vpcs_instance.vpcs = self._vpcs + vpcs_instance.start() + except vpcsError as e: + self.send_custom_error(str(e)) + return + self.send_response(True) + + @IModule.route("vpcs.stop") + def vm_stop(self, request): + """ + Stops an vpcs instance. + + Mandatory request parameters: + - id (vpcs instance identifier) + + Response parameters: + - True on success + + :param request: JSON request + """ + + # validate the request + if not self.validate_request(request, VPCS_STOP_SCHEMA): + return + + # get the instance + vpcs_instance = self.get_vpcs_instance(request["id"]) + if not vpcs_instance: + return + + try: + vpcs_instance.stop() + except vpcsError as e: + self.send_custom_error(str(e)) + return + self.send_response(True) + + @IModule.route("vpcs.reload") + def vm_reload(self, request): + """ + Reloads an vpcs instance. + + Mandatory request parameters: + - id (vpcs identifier) + + Response parameters: + - True on success + + :param request: JSON request + """ + + # validate the request + if not self.validate_request(request, VPCS_RELOAD_SCHEMA): + return + + # get the instance + vpcs_instance = self.get_vpcs_instance(request["id"]) + if not vpcs_instance: + return + + try: + if vpcs_instance.is_running(): + vpcs_instance.stop() + vpcs_instance.start() + except vpcsError as e: + self.send_custom_error(str(e)) + return + self.send_response(True) + + @IModule.route("vpcs.allocate_udp_port") + def allocate_udp_port(self, request): + """ + Allocates a UDP port in order to create an UDP NIO. + + Mandatory request parameters: + - id (vpcs identifier) + - port_id (unique port identifier) + + Response parameters: + - port_id (unique port identifier) + - lport (allocated local port) + + :param request: JSON request + """ + + # validate the request + if not self.validate_request(request, VPCS_ALLOCATE_UDP_PORT_SCHEMA): + return + + # get the instance + vpcs_instance = self.get_vpcs_instance(request["id"]) + if not vpcs_instance: + return + + try: + + # find a UDP port + if self._current_udp_port >= self._udp_end_port_range: + self._current_udp_port = self._udp_start_port_range + try: + port = find_unused_port(self._current_udp_port, self._udp_end_port_range, host=self._host, socket_type="UDP") + except Exception as e: + raise vpcsError(e) + self._current_udp_port += 1 + + log.info("{} [id={}] has allocated UDP port {} with host {}".format(vpcs_instance.name, + vpcs_instance.id, + port, + self._host)) + response = {"lport": port} + + except vpcsError as e: + self.send_custom_error(str(e)) + return + + response["port_id"] = request["port_id"] + self.send_response(response) + + def _check_for_privileged_access(self, device): + """ + Check if vpcs can access Ethernet and TAP devices. + + :param device: device name + """ + + # we are root, so vpcs should have privileged access too + if os.geteuid() == 0: + return + + # test if vpcs has the CAP_NET_RAW capability + if "security.capability" in os.listxattr(self._vpcs): + try: + caps = os.getxattr(self._vpcs, "security.capability") + # test the 2nd byte and check if the 13th bit (CAP_NET_RAW) is set + if struct.unpack(". + + +class Adapter(object): + """ + Base class for adapters. + + :param interfaces: number of interfaces supported by this adapter. + """ + + def __init__(self, interfaces=1): + + self._interfaces = interfaces + + self._ports = {} + for port_id in range(0, interfaces): + self._ports[port_id] = None + + def removable(self): + """ + Returns True if the adapter can be removed from a slot + and False if not. + + :returns: boolean + """ + + return True + + def port_exists(self, port_id): + """ + Checks if a port exists on this adapter. + + :returns: True is the port exists, + False otherwise. + """ + + if port_id in self._ports: + return True + return False + + def add_nio(self, port_id, nio): + """ + Adds a NIO to a port on this adapter. + + :param port_id: port ID (integer) + :param nio: NIO instance + """ + + self._ports[port_id] = nio + + def remove_nio(self, port_id): + """ + Removes a NIO from a port on this adapter. + + :param port_id: port ID (integer) + """ + + self._ports[port_id] = None + + def get_nio(self, port_id): + """ + Returns the NIO assigned to a port. + + :params port_id: port ID (integer) + + :returns: NIO instance + """ + + return self._ports[port_id] + + @property + def ports(self): + """ + Returns port to NIO mapping + + :returns: dictionary port -> NIO + """ + + return self._ports + + @property + def interfaces(self): + """ + Returns the number of interfaces supported by this adapter. + + :returns: number of interfaces + """ + + return self._interfaces diff --git a/gns3server/modules/vpcs/adapters/ethernet_adapter.py b/gns3server/modules/vpcs/adapters/ethernet_adapter.py new file mode 100644 index 00000000..bbca7f40 --- /dev/null +++ b/gns3server/modules/vpcs/adapters/ethernet_adapter.py @@ -0,0 +1,31 @@ +# -*- coding: utf-8 -*- +# +# Copyright (C) 2014 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 . + +from .adapter import Adapter + + +class EthernetAdapter(Adapter): + """ + VPCS Ethernet adapter. + """ + + def __init__(self): + Adapter.__init__(self, interfaces=1) + + def __str__(self): + + return "VPCS Ethernet adapter" diff --git a/gns3server/modules/vpcs/nios/nio_tap.py b/gns3server/modules/vpcs/nios/nio_tap.py new file mode 100644 index 00000000..ee550e7b --- /dev/null +++ b/gns3server/modules/vpcs/nios/nio_tap.py @@ -0,0 +1,46 @@ +# -*- coding: utf-8 -*- +# +# Copyright (C) 2013 GNS3 Technologies Inc. +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +""" +Interface for TAP NIOs (UNIX based OSes only). +""" + + +class NIO_TAP(object): + """ + IOU TAP NIO. + + :param tap_device: TAP device name (e.g. tap0) + """ + + def __init__(self, tap_device): + + self._tap_device = tap_device + + @property + def tap_device(self): + """ + Returns the TAP device used by this NIO. + + :returns: the TAP device name + """ + + return self._tap_device + + def __str__(self): + + return "NIO TAP" diff --git a/gns3server/modules/vpcs/nios/nio_udp.py b/gns3server/modules/vpcs/nios/nio_udp.py new file mode 100644 index 00000000..3142d70e --- /dev/null +++ b/gns3server/modules/vpcs/nios/nio_udp.py @@ -0,0 +1,72 @@ +# -*- coding: utf-8 -*- +# +# Copyright (C) 2013 GNS3 Technologies Inc. +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +""" +Interface for UDP NIOs. +""" + + +class NIO_UDP(object): + """ + IOU UDP NIO. + + :param lport: local port number + :param rhost: remote address/host + :param rport: remote port number + """ + + _instance_count = 0 + + def __init__(self, lport, rhost, rport): + + self._lport = lport + self._rhost = rhost + self._rport = rport + + @property + def lport(self): + """ + Returns the local port + + :returns: local port number + """ + + return self._lport + + @property + def rhost(self): + """ + Returns the remote host + + :returns: remote address/host + """ + + return self._rhost + + @property + def rport(self): + """ + Returns the remote port + + :returns: remote port number + """ + + return self._rport + + def __str__(self): + + return "NIO UDP" diff --git a/gns3server/modules/vpcs/schemas.py b/gns3server/modules/vpcs/schemas.py new file mode 100644 index 00000000..d1061384 --- /dev/null +++ b/gns3server/modules/vpcs/schemas.py @@ -0,0 +1,306 @@ +# -*- coding: utf-8 -*- +# +# Copyright (C) 2014 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 . + + +VPCS_CREATE_SCHEMA = { + "$schema": "http://json-schema.org/draft-04/schema#", + "description": "Request validation to create a new VPCS instance", + "type": "object", + "properties": { + "name": { + "description": "VPCS device name", + "type": "string", + "minLength": 1, + }, + "path": { + "description": "path to the VPCS executable", + "type": "string", + "minLength": 1, + } + }, + "required": ["path"] +} + +VPCS_DELETE_SCHEMA = { + "$schema": "http://json-schema.org/draft-04/schema#", + "description": "Request validation to delete an VPCS instance", + "type": "object", + "properties": { + "id": { + "description": "VPCS device instance ID", + "type": "integer" + }, + }, + "required": ["id"] +} + +VPCS_UPDATE_SCHEMA = { + "$schema": "http://json-schema.org/draft-04/schema#", + "description": "Request validation to update an VPCS instance", + "type": "object", + "properties": { + "id": { + "description": "VPCS device instance ID", + "type": "integer" + }, + "name": { + "description": "VPCS device name", + "type": "string", + "minLength": 1, + }, + "path": { + "description": "path to the VPCS executable", + "type": "string", + "minLength": 1, + }, + "script_file": { + "description": "path to the VPCS startup configuration file", + "type": "string", + "minLength": 1, + }, + "script_file_base64": { + "description": "startup configuration base64 encoded", + "type": "string" + }, + }, + "required": ["id"] +} + +VPCS_START_SCHEMA = { + "$schema": "http://json-schema.org/draft-04/schema#", + "description": "Request validation to start an VPCS instance", + "type": "object", + "properties": { + "id": { + "description": "VPCS device instance ID", + "type": "integer" + }, + }, + "required": ["id"] +} + +VPCS_STOP_SCHEMA = { + "$schema": "http://json-schema.org/draft-04/schema#", + "description": "Request validation to stop an VPCS instance", + "type": "object", + "properties": { + "id": { + "description": "VPCS device instance ID", + "type": "integer" + }, + }, + "required": ["id"] +} + +VPCS_RELOAD_SCHEMA = { + "$schema": "http://json-schema.org/draft-04/schema#", + "description": "Request validation to reload an VPCS instance", + "type": "object", + "properties": { + "id": { + "description": "VPCS device instance ID", + "type": "integer" + }, + }, + "required": ["id"] +} + +VPCS_ALLOCATE_UDP_PORT_SCHEMA = { + "$schema": "http://json-schema.org/draft-04/schema#", + "description": "Request validation to allocate an UDP port for an VPCS instance", + "type": "object", + "properties": { + "id": { + "description": "VPCS device instance ID", + "type": "integer" + }, + "port_id": { + "description": "Unique port identifier for the VPCS instance", + "type": "integer" + }, + }, + "required": ["id", "port_id"] +} + +VPCS_ADD_NIO_SCHEMA = { + "$schema": "http://json-schema.org/draft-04/schema#", + "description": "Request validation to add a NIO for an VPCS instance", + "type": "object", + + "definitions": { + "UDP": { + "description": "UDP Network Input/Output", + "properties": { + "type": { + "enum": ["nio_udp"] + }, + "lport": { + "description": "Local port", + "type": "integer", + "minimum": 1, + "maximum": 65535 + }, + "rhost": { + "description": "Remote host", + "type": "string", + "minLength": 1 + }, + "rport": { + "description": "Remote port", + "type": "integer", + "minimum": 1, + "maximum": 65535 + } + }, + "required": ["type", "lport", "rhost", "rport"], + "additionalProperties": False + }, + "Ethernet": { + "description": "Generic Ethernet Network Input/Output", + "properties": { + "type": { + "enum": ["nio_generic_ethernet"] + }, + "ethernet_device": { + "description": "Ethernet device name e.g. eth0", + "type": "string", + "minLength": 1 + }, + }, + "required": ["type", "ethernet_device"], + "additionalProperties": False + }, + "LinuxEthernet": { + "description": "Linux Ethernet Network Input/Output", + "properties": { + "type": { + "enum": ["nio_linux_ethernet"] + }, + "ethernet_device": { + "description": "Ethernet device name e.g. eth0", + "type": "string", + "minLength": 1 + }, + }, + "required": ["type", "ethernet_device"], + "additionalProperties": False + }, + "TAP": { + "description": "TAP Network Input/Output", + "properties": { + "type": { + "enum": ["nio_tap"] + }, + "tap_device": { + "description": "TAP device name e.g. tap0", + "type": "string", + "minLength": 1 + }, + }, + "required": ["type", "tap_device"], + "additionalProperties": False + }, + "UNIX": { + "description": "UNIX Network Input/Output", + "properties": { + "type": { + "enum": ["nio_unix"] + }, + "local_file": { + "description": "path to the UNIX socket file (local)", + "type": "string", + "minLength": 1 + }, + "remote_file": { + "description": "path to the UNIX socket file (remote)", + "type": "string", + "minLength": 1 + }, + }, + "required": ["type", "local_file", "remote_file"], + "additionalProperties": False + }, + "VDE": { + "description": "VDE Network Input/Output", + "properties": { + "type": { + "enum": ["nio_vde"] + }, + "control_file": { + "description": "path to the VDE control file", + "type": "string", + "minLength": 1 + }, + "local_file": { + "description": "path to the VDE control file", + "type": "string", + "minLength": 1 + }, + }, + "required": ["type", "control_file", "local_file"], + "additionalProperties": False + }, + "NULL": { + "description": "NULL Network Input/Output", + "properties": { + "type": { + "enum": ["nio_null"] + }, + }, + "required": ["type"], + "additionalProperties": False + }, + }, + + "properties": { + "id": { + "description": "VPCS device instance ID", + "type": "integer" + }, + "port_id": { + "description": "Unique port identifier for the VPCS instance", + "type": "integer" + }, + "nio": { + "type": "object", + "description": "Network Input/Output", + "oneOf": [ + {"$ref": "#/definitions/UDP"}, + {"$ref": "#/definitions/Ethernet"}, + {"$ref": "#/definitions/LinuxEthernet"}, + {"$ref": "#/definitions/TAP"}, + {"$ref": "#/definitions/UNIX"}, + {"$ref": "#/definitions/VDE"}, + {"$ref": "#/definitions/NULL"}, + ] + }, + }, + "required": ["id", "port_id", "nio"] +} + +VPCS_DELETE_NIO_SCHEMA = { + "$schema": "http://json-schema.org/draft-04/schema#", + "description": "Request validation to delete a NIO for an VPCS instance", + "type": "object", + "properties": { + "id": { + "description": "VPCS device instance ID", + "type": "integer" + }, + }, + "required": ["id"] +} diff --git a/gns3server/modules/vpcs/vpcs_device.py b/gns3server/modules/vpcs/vpcs_device.py new file mode 100644 index 00000000..05de81d1 --- /dev/null +++ b/gns3server/modules/vpcs/vpcs_device.py @@ -0,0 +1,469 @@ +# -*- coding: utf-8 -*- +# +# Copyright (C) 2014 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 . + +""" +vpcs device management (creates command line, processes, files etc.) in +order to run an vpcs instance. +""" + +import os +import re +import signal +import subprocess +import argparse +import threading +import configparser +import sys +import socket +from .vpcs_error import VPCSError +from .adapters.ethernet_adapter import EthernetAdapter +from .nios.nio_udp import NIO_UDP +from .nios.nio_tap import NIO_TAP + +import logging +log = logging.getLogger(__name__) + + +class VPCSDevice(object): + """ + vpcs device implementation. + + :param path: path to vpcs executable + :param working_dir: path to a working directory + :param host: host/address to bind for console and UDP connections + :param name: name of this vpcs device + """ + + _instances = [] + + def __init__(self, path, working_dir, host="127.0.0.1", name=None): + + # find an instance identifier (1 <= id <= 255) + # This 255 limit is due to a restriction on the number of possible + # mac addresses given in vpcs using the -m option + self._id = 0 + for identifier in range(1, 256): + if identifier not in self._instances: + self._id = identifier + self._instances.append(self._id) + break + + if self._id == 0: + raise vpcsError("Maximum number of vpcs instances reached") + + if name: + self._name = name + else: + self._name = "vpcs{}".format(self._id) + self._path = path + self._console = None + self._working_dir = None + self._command = [] + self._process = None + self._vpcs_stdout_file = "" + self._host = "127.0.0.1" + self._started = False + + # vpcs settings + self._script_file = "" + self._ethernet_adapters = [EthernetAdapter()] # one adapter = 1 interfaces + self._slots = self._ethernet_adapters + + # update the working directory + self.working_dir = working_dir + + log.info("vpcs device {name} [id={id}] has been created".format(name=self._name, + id=self._id)) + + def defaults(self): + """ + Returns all the default attribute values for vpcs. + + :returns: default values (dictionary) + """ + + vpcs_defaults = {"name": self._name, + "path": self._path, + "script_file": self._script_file, + "console": self._console} + + return vpcs_defaults + + @property + def id(self): + """ + Returns the unique ID for this vpcs device. + + :returns: id (integer) + """ + + return(self._id) + + @classmethod + def reset(cls): + """ + Resets allocated instance list. + """ + + cls._instances.clear() + + @property + def name(self): + """ + Returns the name of this vpcs device. + + :returns: name + """ + + return self._name + + @name.setter + def name(self, new_name): + """ + Sets the name of this vpcs device. + + :param new_name: name + """ + + self._name = new_name + log.info("vpcs {name} [id={id}]: renamed to {new_name}".format(name=self._name, + id=self._id, + new_name=new_name)) + + @property + def path(self): + """ + Returns the path to the vpcs executable. + + :returns: path to vpcs + """ + + return(self._path) + + @path.setter + def path(self, path): + """ + Sets the path to the vpcs executable. + + :param path: path to vpcs + """ + + self._path = path + log.info("vpcs {name} [id={id}]: path changed to {path}".format(name=self._name, + id=self._id, + path=path)) + + @property + def working_dir(self): + """ + Returns current working directory + + :returns: path to the working directory + """ + + return self._working_dir + + @working_dir.setter + def working_dir(self, working_dir): + """ + Sets the working directory for vpcs. + + :param working_dir: path to the working directory + """ + + # create our own working directory + working_dir = os.path.join(working_dir, "vpcs", "device-{}".format(self._id)) + try: + os.makedirs(working_dir) + except FileExistsError: + pass + except OSError as e: + raise vpcsError("Could not create working directory {}: {}".format(working_dir, e)) + + self._working_dir = working_dir + log.info("vpcs {name} [id={id}]: working directory changed to {wd}".format(name=self._name, + id=self._id, + wd=self._working_dir)) + + @property + def console(self): + """ + Returns the TCP console port. + + :returns: console port (integer) + """ + + return self._console + + @console.setter + def console(self, console): + """ + Sets the TCP console port. + + :param console: console port (integer) + """ + + self._console = console + log.info("vpcs {name} [id={id}]: console port set to {port}".format(name=self._name, + id=self._id, + port=console)) + + def command(self): + """ + Returns the vpcs command line. + + :returns: vpcs command line (string) + """ + + return " ".join(self._build_command()) + + def delete(self): + """ + Deletes this vpcs device. + """ + + self.stop() + self._instances.remove(self._id) + log.info("vpcs device {name} [id={id}] has been deleted".format(name=self._name, + id=self._id)) + + @property + def started(self): + """ + Returns either this vpcs device has been started or not. + + :returns: boolean + """ + + return self._started + + def start(self): + """ + Starts the vpcs process. + """ + + if not self.is_running(): + + if not os.path.isfile(self._path): + raise vpcsError("vpcs image '{}' is not accessible".format(self._path)) + + if not os.access(self._path, os.X_OK): + raise vpcsError("vpcs image '{}' is not executable".format(self._path)) + + self._command = self._build_command() + try: + log.info("starting vpcs: {}".format(self._command)) + self._vpcs_stdout_file = os.path.join(self._working_dir, "vpcs.log") + log.info("logging to {}".format(self._vpcs_stdout_file)) + with open(self._vpcs_stdout_file, "w") as fd: + self._process = subprocess.Popen(self._command, + stdout=fd, + stderr=subprocess.STDOUT, + cwd=self._working_dir) + log.info("vpcs instance {} started PID={}".format(self._id, self._process.pid)) + self._started = True + except OSError as e: + vpcs_stdout = self.read_vpcs_stdout() + log.error("could not start vpcs {}: {}\n{}".format(self._path, e, vpcs_stdout)) + raise vpcsError("could not start vpcs {}: {}\n{}".format(self._path, e, vpcs_stdout)) + + def stop(self): + """ + Stops the vpcs process. + """ + + # stop the vpcs process + if self.is_running(): + log.info("stopping vpcs instance {} PID={}".format(self._id, self._process.pid)) + try: + sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + sock.connect((self._host, self._console)) + sock.send(bytes("quit\n", 'UTF-8')) + sock.close() + except TypeError as e: + log.warn("vpcs instance {} PID={} is still running. Error: {}".format(self._id, + self._process.pid, e)) + self._process = None + self._started = False + + + def read_vpcs_stdout(self): + """ + Reads the standard output of the vpcs process. + Only use when the process has been stopped or has crashed. + """ + + output = "" + if self._vpcs_stdout_file: + try: + with open(self._vpcs_stdout_file) as file: + output = file.read() + except OSError as e: + log.warn("could not read {}: {}".format(self._vpcs_stdout_file, e)) + return output + + def is_running(self): + """ + Checks if the vpcs process is running + + :returns: True or False + """ + + if self._process: + try: + sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + sock.connect((self._host, self._console)) + sock.close() + return True + except: + e = sys.exc_info()[0] + log.warn("Could not connect to {}:{}. Error: {}".format(self._host, self._console, e)) + return False + return False + + + def slot_add_nio_binding(self, slot_id, port_id, nio): + """ + Adds a slot NIO binding. + + :param slot_id: slot ID + :param port_id: port ID + :param nio: NIO instance to add to the slot/port + """ + + try: + adapter = self._slots[slot_id] + except IndexError: + raise vpcsError("Slot {slot_id} doesn't exist on vpcs {name}".format(name=self._name, + slot_id=slot_id)) + + if not adapter.port_exists(port_id): + raise vpcsError("Port {port_id} doesn't exist in adapter {adapter}".format(adapter=adapter, + port_id=port_id)) + + adapter.add_nio(port_id, nio) + log.info("vpcs {name} [id={id}]: {nio} added to {slot_id}/{port_id}".format(name=self._name, + id=self._id, + nio=nio, + slot_id=slot_id, + port_id=port_id)) + + def slot_remove_nio_binding(self, slot_id, port_id): + """ + Removes a slot NIO binding. + + :param slot_id: slot ID + :param port_id: port ID + """ + + try: + adapter = self._slots[slot_id] + except IndexError: + raise vpcsError("Slot {slot_id} doesn't exist on vpcs {name}".format(name=self._name, + slot_id=slot_id)) + + if not adapter.port_exists(port_id): + raise vpcsError("Port {port_id} doesn't exist in adapter {adapter}".format(adapter=adapter, + port_id=port_id)) + + nio = adapter.get_nio(port_id) + adapter.remove_nio(port_id) + log.info("vpcs {name} [id={id}]: {nio} removed from {slot_id}/{port_id}".format(name=self._name, + id=self._id, + nio=nio, + slot_id=slot_id, + port_id=port_id)) + + def _build_command(self): + """ + Command to start the vpcs process. + (to be passed to subprocess.Popen()) + + vpcs command line: + usage: vpcs [options] [scriptfile] + Option: + -h print this help then exit + -v print version information then exit + + -p port run as a daemon listening on the tcp 'port' + -m num start byte of ether address, default from 0 + -r file load and execute script file + compatible with older versions, DEPRECATED. + + -e tap mode, using /dev/tapx (linux only) + -u udp mode, default + + udp mode options: + -s port local udp base port, default from 20000 + -c port remote udp base port (dynamips udp port), default from 30000 + -t ip remote host IP, default 127.0.0.1 + + hypervisor mode option: + -H port run as the hypervisor listening on the tcp 'port' + + If no 'scriptfile' specified, vpcs will read and execute the file named + 'startup.vpc' if it exsits in the current directory. + + """ + + command = [self._path] + command.extend(["-p", str(self._console)]) + + for adapter in self._slots: + for unit in adapter.ports.keys(): + nio = adapter.get_nio(unit) + if nio: + if isinstance(nio, NIO_UDP): + # UDP tunnel + command.extend(["-s", str(nio.lport)]) + command.extend(["-c", str(nio.rport)]) + command.extend(["-t", str(nio.rhost)]) + + elif isinstance(nio, NIO_TAP): + # TAP interface + command.extend(["-e"]) #, str(nio.tap_device)]) #TODO: Fix, currently vpcs doesn't allow specific tap_device + + command.extend(["-m", str(self._id)]) #The unique ID is used to set the mac address offset + command.extend(["-i", str(1)]) #Option to start only one pc instance + if self._script_file: + command.extend([self._script_file]) + return command + + @property + def script_file(self): + """ + Returns the script-file for this vpcs instance. + + :returns: path to script-file file + """ + + return self._script_file + + @script_file.setter + def script_file(self, script_file): + """ + Sets the script-file for this vpcs instance. + + :param script_file: path to script-file file + """ + + self._script_file = script_file + log.info("vpcs {name} [id={id}]: script_file set to {config}".format(name=self._name, + id=self._id, + config=self._script_file)) + + diff --git a/gns3server/modules/vpcs/vpcs_error.py b/gns3server/modules/vpcs/vpcs_error.py new file mode 100644 index 00000000..167129ba --- /dev/null +++ b/gns3server/modules/vpcs/vpcs_error.py @@ -0,0 +1,39 @@ +# -*- coding: utf-8 -*- +# +# Copyright (C) 2013 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 . + +""" +Custom exceptions for VPCS module. +""" + + +class VPCSError(Exception): + + def __init__(self, message, original_exception=None): + + Exception.__init__(self, message) + if isinstance(message, Exception): + message = str(message) + self._message = message + self._original_exception = original_exception + + def __repr__(self): + + return self._message + + def __str__(self): + + return self._message diff --git a/tests/vpcs/test_vpcs_device.py b/tests/vpcs/test_vpcs_device.py new file mode 100644 index 00000000..13609506 --- /dev/null +++ b/tests/vpcs/test_vpcs_device.py @@ -0,0 +1,29 @@ +from gns3server.modules.vpcs import VPCSDevice +import os +import pytest + + +@pytest.fixture(scope="session") +def vpcs(request): + + cwd = os.path.dirname(os.path.abspath(__file__)) + vpcs_path = os.path.join(cwd, "vpcs") + vpcs_device = VPCSDevice(vpcs_path, "/tmp") + vpcs_device.start() + request.addfinalizer(vpcs_device.delete) + return vpcs_device + + +def test_vpcs_is_started(vpcs): + + print(vpcs.command()) + assert vpcs.id == 1 # we should have only one VPCS running! + assert vpcs.is_running() + + +def test_vpcs_restart(vpcs): + + vpcs.stop() + assert not vpcs.is_running() + vpcs.start() + assert vpcs.is_running()