diff --git a/gns3server/modules/__init__.py b/gns3server/modules/__init__.py index 7fd76401..5bd4c110 100644 --- a/gns3server/modules/__init__.py +++ b/gns3server/modules/__init__.py @@ -19,9 +19,9 @@ import sys from .base import IModule from .dynamips import Dynamips from .vpcs import VPCS +from .virtualbox import VirtualBox -MODULES = [Dynamips] -MODULES.append(VPCS) +MODULES = [Dynamips, VPCS, VirtualBox] if sys.platform.startswith("linux"): # IOU runs only on Linux diff --git a/gns3server/modules/virtualbox/__init__.py b/gns3server/modules/virtualbox/__init__.py new file mode 100644 index 00000000..ee944615 --- /dev/null +++ b/gns3server/modules/virtualbox/__init__.py @@ -0,0 +1,540 @@ +# -*- 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 . + +""" +VirtualBox server module. +""" + +import os +import base64 +import socket +import shutil + +from gns3server.modules import IModule +from gns3server.config import Config +from .virtualbox_vm import VirtualBoxVM +from .virtualbox_error import VirtualBoxError +from .nios.nio_udp import NIO_UDP +from ..attic import find_unused_port + +#from .schemas import VBOX_CREATE_SCHEMA +#from .schemas import VBOX_DELETE_SCHEMA +#from .schemas import VBOX_UPDATE_SCHEMA +#from .schemas import VBOX_START_SCHEMA +#from .schemas import VBOX_STOP_SCHEMA +#from .schemas import VBOX_RELOAD_SCHEMA +#from .schemas import VBOX_ALLOCATE_UDP_PORT_SCHEMA +#from .schemas import VBOX_ADD_NIO_SCHEMA +#from .schemas import VBOX_DELETE_NIO_SCHEMA + +import logging +log = logging.getLogger(__name__) + + +class VirtualBox(IModule): + """ + VirtualBox module. + + :param name: module name + :param args: arguments for the module + :param kwargs: named arguments for the module + """ + + def __init__(self, name, *args, **kwargs): + + # a new process start when calling IModule + IModule.__init__(self, name, *args, **kwargs) + self._vbox_instances = {} + + config = Config.instance() + vbox_config = config.get_section_config(name.upper()) + self._console_start_port_range = vbox_config.get("console_start_port_range", 3501) + self._console_end_port_range = vbox_config.get("console_end_port_range", 4000) + self._allocated_udp_ports = [] + self._udp_start_port_range = vbox_config.get("udp_start_port_range", 35001) + self._udp_end_port_range = vbox_config.get("udp_end_port_range", 35500) + self._host = kwargs["host"] + self._projects_dir = kwargs["projects_dir"] + self._tempdir = kwargs["temp_dir"] + self._working_dir = self._projects_dir + + def stop(self, signum=None): + """ + Properly stops the module. + + :param signum: signal number (if called by the signal handler) + """ + + # delete all VirtualBox instances + for vbox_id in self._vbox_instances: + vbox_instance = self._vbox_instances[vbox_id] + vbox_instance.delete() + + IModule.stop(self, signum) # this will stop the I/O loop + + def get_vbox_instance(self, vbox_id): + """ + Returns a VirtualBox VM instance. + + :param vbox_id: VirtualBox device identifier + + :returns: VBoxDevice instance + """ + + if vbox_id not in self._vbox_instances: + log.debug("VirtualBox VM ID {} doesn't exist".format(vbox_id), exc_info=1) + self.send_custom_error("VirtualBox VM ID {} doesn't exist".format(vbox_id)) + return None + return self._vbox_instances[vbox_id] + + @IModule.route("virtualbox.reset") + def reset(self, request): + """ + Resets the module. + + :param request: JSON request + """ + + # delete all VirtualBox instances + for vbox_id in self._vbox_instances: + vbox_instance = self._vbox_instances[vbox_id] + vbox_instance.delete() + + # resets the instance IDs + VirtualBoxVM.reset() + + self._vbox_instances.clear() + self._allocated_udp_ports.clear() + + log.info("VirtualBox module has been reset") + + @IModule.route("virtualbox.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 is None: + self.send_param_error() + return + + 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"]) + 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 vbox_id in self._vbox_instances: + vbox_instance = self._vbox_instances[vbox_id] + vbox_instance.working_dir = os.path.join(self._working_dir, "vbox", "vm-{}".format(vbox_instance.id)) + + 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)) + + @IModule.route("virtualbox.create") + def vbox_create(self, request): + """ + Creates a new VirtualBox VM instance. + + Mandatory request parameters: + - name (VirtualBox VM name) + + Optional request parameters: + - console (VirtualBox VM console port) + + Response parameters: + - id (VirtualBox VM instance identifier) + - name (VirtualBox VM name) + - default settings + + :param request: JSON request + """ + + # validate the request + #if not self.validate_request(request, VBOX_CREATE_SCHEMA): + # return + + name = request["name"] + console = request.get("console") + vbox_id = request.get("vbox_id") + + try: + + vbox_instance = VirtualBoxVM(name, + self._working_dir, + self._host, + vbox_id, + console, + self._console_start_port_range, + self._console_end_port_range) + + except VirtualBoxError as e: + self.send_custom_error(str(e)) + return + + response = {"name": vbox_instance.name, + "id": vbox_instance.id} + + defaults = vbox_instance.defaults() + response.update(defaults) + self._vbox_instances[vbox_instance.id] = vbox_instance + self.send_response(response) + + @IModule.route("virtualbox.delete") + def vbox_delete(self, request): + """ + Deletes a VirtualBox VM instance. + + Mandatory request parameters: + - id (VirtualBox VM instance identifier) + + Response parameter: + - True on success + + :param request: JSON request + """ + + # validate the request + #if not self.validate_request(request, VBOX_DELETE_SCHEMA): + # return + + # get the instance + vbox_instance = self.get_vbox_instance(request["id"]) + if not vbox_instance: + return + + try: + vbox_instance.clean_delete() + del self._vbox_instances[request["id"]] + except VirtualBoxError as e: + self.send_custom_error(str(e)) + return + + self.send_response(True) + + @IModule.route("virtualbox.update") + def vbox_update(self, request): + """ + Updates a VirtualBox VM instance + + Mandatory request parameters: + - id (VirtualBox VM instance identifier) + + Optional request parameters: + - any setting to update + + Response parameters: + - updated settings + + :param request: JSON request + """ + + # validate the request + #if not self.validate_request(request, VBOX_UPDATE_SCHEMA): + # return + + # get the instance + vbox_instance = self.get_vbox_instance(request["id"]) + if not vbox_instance: + return + + # update the VirtualBox VM settings + response = {} + for name, value in request.items(): + if hasattr(vbox_instance, name) and getattr(vbox_instance, name) != value: + try: + setattr(vbox_instance, name, value) + response[name] = value + except VirtualBoxError as e: + self.send_custom_error(str(e)) + return + + self.send_response(response) + + @IModule.route("virtualbox.start") + def vbox_start(self, request): + """ + Starts a VirtualBox VM instance. + + Mandatory request parameters: + - id (VirtualBox VM instance identifier) + + Response parameters: + - True on success + + :param request: JSON request + """ + + # validate the request + #if not self.validate_request(request, VBOX_START_SCHEMA): + # return + + # get the instance + vbox_instance = self.get_vbox_instance(request["id"]) + if not vbox_instance: + return + + try: + vbox_instance.start() + except VirtualBoxError as e: + self.send_custom_error(str(e)) + return + self.send_response(True) + + @IModule.route("virtualbox.stop") + def vbox_stop(self, request): + """ + Stops a VirtualBox VM instance. + + Mandatory request parameters: + - id (VirtualBox VM instance identifier) + + Response parameters: + - True on success + + :param request: JSON request + """ + + # validate the request + #if not self.validate_request(request, VBOX_STOP_SCHEMA): + # return + + # get the instance + vbox_instance = self.get_vbox_instance(request["id"]) + if not vbox_instance: + return + + try: + vbox_instance.stop() + except VirtualBoxError as e: + self.send_custom_error(str(e)) + return + self.send_response(True) + + @IModule.route("virtualbox.reload") + def vbox_reload(self, request): + """ + Reloads a VirtualBox VM instance. + + Mandatory request parameters: + - id (VirtualBox VM identifier) + + Response parameters: + - True on success + + :param request: JSON request + """ + + # validate the request + #if not self.validate_request(request, VBOX_RELOAD_SCHEMA): + # return + + # get the instance + vbox_instance = self.get_vpcs_instance(request["id"]) + if not vbox_instance: + return + + try: + if vbox_instance.is_running(): + vbox_instance.stop() + vbox_instance.start() + except VirtualBoxError as e: + self.send_custom_error(str(e)) + return + self.send_response(True) + + @IModule.route("virtualbox.allocate_udp_port") + def allocate_udp_port(self, request): + """ + Allocates a UDP port in order to create an UDP NIO. + + Mandatory request parameters: + - id (VirtualBox VM 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, VBOX_ALLOCATE_UDP_PORT_SCHEMA): + # return + + # get the instance + vbox_instance = self.get_vbox_instance(request["id"]) + if not vbox_instance: + return + + try: + port = find_unused_port(self._udp_start_port_range, + self._udp_end_port_range, + host=self._host, + socket_type="UDP", + ignore_ports=self._allocated_udp_ports) + except Exception as e: + self.send_custom_error(str(e)) + return + + self._allocated_udp_ports.append(port) + log.info("{} [id={}] has allocated UDP port {} with host {}".format(vbox_instance.name, + vbox_instance.id, + port, + self._host)) + + response = {"lport": port, + "port_id": request["port_id"]} + self.send_response(response) + + @IModule.route("virtualbox.add_nio") + def add_nio(self, request): + """ + Adds an NIO (Network Input/Output) for a VirtualBox VM instance. + + Mandatory request parameters: + - id (VirtualBox VM instance identifier) + - port (port number) + - port_id (unique port identifier) + - nio (one of the following) + - type "nio_udp" + - lport (local port) + - rhost (remote host) + - rport (remote port) + + Response parameters: + - port_id (unique port identifier) + + :param request: JSON request + """ + + # validate the request + #if not self.validate_request(request, VBOX_ADD_NIO_SCHEMA): + # return + + # get the instance + vbox_instance = self.get_vbox_instance(request["id"]) + if not vbox_instance: + return + + port = request["port"] + try: + nio = None + if request["nio"]["type"] == "nio_udp": + lport = request["nio"]["lport"] + rhost = request["nio"]["rhost"] + rport = request["nio"]["rport"] + try: + #TODO: handle IPv6 + with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as sock: + sock.connect((rhost, rport)) + except OSError as e: + raise VirtualBoxError("Could not create an UDP connection to {}:{}: {}".format(rhost, rport, e)) + nio = NIO_UDP(lport, rhost, rport) + if not nio: + raise VirtualBoxError("Requested NIO does not exist or is not supported: {}".format(request["nio"]["type"])) + except VirtualBoxError as e: + self.send_custom_error(str(e)) + return + + try: + vbox_instance.port_add_nio_binding(port, nio) + except VirtualBoxError as e: + self.send_custom_error(str(e)) + return + + self.send_response({"port_id": request["port_id"]}) + + @IModule.route("virtualbox.delete_nio") + def delete_nio(self, request): + """ + Deletes an NIO (Network Input/Output). + + Mandatory request parameters: + - id (VPCS instance identifier) + - port (port identifier) + + Response parameters: + - True on success + + :param request: JSON request + """ + + # validate the request + #if not self.validate_request(request, VBOX_DELETE_NIO_SCHEMA): + # return + + # get the instance + vbox_instance = self.get_vbox_instance(request["id"]) + if not vbox_instance: + return + + port = request["port"] + try: + nio = vbox_instance.port_remove_nio_binding(port) + if isinstance(nio, NIO_UDP) and nio.lport in self._allocated_udp_ports: + self._allocated_udp_ports.remove(nio.lport) + except VirtualBoxError as e: + self.send_custom_error(str(e)) + return + + self.send_response(True) + + @IModule.route("virtualbox.echo") + def echo(self, request): + """ + Echo end point for testing purposes. + + :param request: JSON request + """ + + if request is None: + self.send_param_error() + else: + log.debug("received request {}".format(request)) + self.send_response(request) diff --git a/gns3server/modules/virtualbox/adapters/__init__.py b/gns3server/modules/virtualbox/adapters/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/gns3server/modules/virtualbox/adapters/adapter.py b/gns3server/modules/virtualbox/adapters/adapter.py new file mode 100644 index 00000000..cf439427 --- /dev/null +++ b/gns3server/modules/virtualbox/adapters/adapter.py @@ -0,0 +1,104 @@ +# -*- 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 . + + +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/virtualbox/adapters/ethernet_adapter.py b/gns3server/modules/virtualbox/adapters/ethernet_adapter.py new file mode 100644 index 00000000..8951ee8d --- /dev/null +++ b/gns3server/modules/virtualbox/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): + """ + VirtualBox Ethernet adapter. + """ + + def __init__(self): + Adapter.__init__(self, interfaces=1) + + def __str__(self): + + return "VirtualBox Ethernet adapter" diff --git a/gns3server/modules/virtualbox/nios/__init__.py b/gns3server/modules/virtualbox/nios/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/gns3server/modules/virtualbox/nios/nio_udp.py b/gns3server/modules/virtualbox/nios/nio_udp.py new file mode 100644 index 00000000..3142d70e --- /dev/null +++ b/gns3server/modules/virtualbox/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/virtualbox/vboxwrapper_client.py b/gns3server/modules/virtualbox/vboxwrapper_client.py new file mode 100644 index 00000000..d1d5d0ed --- /dev/null +++ b/gns3server/modules/virtualbox/vboxwrapper_client.py @@ -0,0 +1,357 @@ +# -*- 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 . + +""" +Client to VirtualBox wrapper. +""" + +import os +import time +import subprocess +import tempfile +import socket +import re + +from .virtualbox_error import VirtualBoxError + +import logging +log = logging.getLogger(__name__) + + +class VboxWrapperClient(object): + """ + VirtualBox Wrapper client. + + :param path: path to VirtualBox wrapper executable + :param working_dir: working directory + :param port: port + :param host: host/address + """ + + # Used to parse the VirtualBox wrapper response codes + error_re = re.compile(r"""^2[0-9]{2}-""") + success_re = re.compile(r"""^1[0-9]{2}\s{1}""") + + def __init__(self, path, working_dir, host, port=11525, timeout=30.0): + + self._path = path + self._command = [] + self._process = None + self._stdout_file = "" + self._started = False + self._host = host + self._port = port + self._timeout = timeout + self._socket = None + + @property + def started(self): + """ + Returns either VirtualBox wrapper has been started or not. + + :returns: boolean + """ + + return self._started + + @property + def path(self): + """ + Returns the path to the VirtualBox wrapper executable. + + :returns: path to VirtualBox wrapper + """ + + return self._path + + @path.setter + def path(self, path): + """ + Sets the path to the VirtualBox wrapper executable. + + :param path: path to VirtualBox wrapper + """ + + self._path = path + + @property + def port(self): + """ + Returns the port used to start the VirtualBox wrapper. + + :returns: port number (integer) + """ + + return self._port + + @port.setter + def port(self, port): + """ + Sets the port used to start the VirtualBox wrapper. + + :param port: port number (integer) + """ + + self._port = port + + @property + def host(self): + """ + Returns the host (binding) used to start the VirtualBox wrapper. + + :returns: host/address (string) + """ + + return self._host + + @host.setter + def host(self, host): + """ + Sets the host (binding) used to start the VirtualBox wrapper. + + :param host: host/address (string) + """ + + self._host = host + + def start(self): + """ + Starts the VirtualBox wrapper process. + """ + + self._command = self._build_command() + try: + log.info("starting VirtualBox wrapper: {}".format(self._command)) + with tempfile.NamedTemporaryFile(delete=False) as fd: + self._stdout_file = fd.name + log.info("VirtualBox wrapper process logging to {}".format(fd.name)) + self._process = subprocess.Popen(self._command, + stdout=fd, + stderr=subprocess.STDOUT, + cwd=self._working_dir) + log.info("VirtualBox wrapper started PID={}".format(self._process.pid)) + self._started = True + except OSError as e: + log.error("could not start VirtualBox wrapper: {}".format(e)) + raise VirtualBoxError("could not start VirtualBox wrapper: {}".format(e)) + + def stop(self): + """ + Stops the VirtualBox wrapper process. + """ + + if self.is_running(): + self.send("hypervisor stop") + self._socket.shutdown(socket.SHUT_RDWR) + self._socket.close() + self._socket = None + log.info("stopping VirtualBox wrapper PID={}".format(self._process.pid)) + try: + # give some time for the VirtualBox wrapper to properly stop. + time.sleep(0.01) + self._process.terminate() + self._process.wait(1) + except subprocess.TimeoutExpired: + self._process.kill() + if self._process.poll() is None: + log.warn("VirtualBox wrapper process {} is still running".format(self._process.pid)) + + if self._stdout_file and os.access(self._stdout_file, os.W_OK): + try: + os.remove(self._stdout_file) + except OSError as e: + log.warning("could not delete temporary VirtualBox wrapper log file: {}".format(e)) + self._started = False + + def read_stdout(self): + """ + Reads the standard output of the VirtualBox wrapper process. + Only use when the process has been stopped or has crashed. + """ + + output = "" + if self._stdout_file and os.access(self._stdout_file, os.R_OK): + try: + with open(self._stdout_file, errors="replace") as file: + output = file.read() + except OSError as e: + log.warn("could not read {}: {}".format(self._stdout_file, e)) + return output + + def is_running(self): + """ + Checks if the process is running + + :returns: True or False + """ + + if self._process and self._process.poll() is None: + return True + return False + + def _build_command(self): + """ + Command to start the VirtualBox wrapper process. + (to be passed to subprocess.Popen()) + """ + + command = [self._path] + #if self._host != "0.0.0.0" and self._host != "::": + # command.extend(["-H", "{}:{}".format(self._host, self._port)]) + #else: + # command.extend(["-H", str(self._port)]) + return command + + def connect(self): + """ + Connects to the VirtualBox wrapper. + """ + + # connect to a local address by default + # if listening to all addresses (IPv4 or IPv6) + if self._host == "0.0.0.0": + host = "127.0.0.1" + elif self._host == "::": + host = "::1" + else: + host = self._host + + try: + self._socket = socket.create_connection((host, self._port), self._timeout) + except OSError as e: + raise VirtualBoxError("Could not connect to the VirtualBox wrapper: {}".format(e)) + + def reset(self): + """ + Resets the VirtualBox wrapper (used to get an empty configuration). + """ + + pass + + @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 the VirtualBox wrapper. + + :param working_dir: path to the working directory + """ + + # encase working_dir in quotes to protect spaces in the path + #self.send("hypervisor working_dir {}".format('"' + working_dir + '"')) + self._working_dir = working_dir + log.debug("working directory set to {}".format(self._working_dir)) + + @property + def socket(self): + """ + Returns the current socket used to communicate with the VirtualBox wrapper. + + :returns: socket instance + """ + + assert self._socket + return self._socket + + def send(self, command): + """ + Sends commands to the VirtualBox wrapper. + + :param command: a VirtualBox wrapper command + + :returns: results as a list + """ + + # VirtualBox wrapper responses are of the form: + # 1xx yyyyyy\r\n + # 1xx yyyyyy\r\n + # ... + # 100-yyyy\r\n + # or + # 2xx-yyyy\r\n + # + # Where 1xx is a code from 100-199 for a success or 200-299 for an error + # The result might be multiple lines and might be less than the buffer size + # but still have more data. The only thing we know for sure is the last line + # will begin with '100-' or a '2xx-' and end with '\r\n' + + if not self._socket: + raise VirtualBoxError("Not connected") + + try: + command = command.strip() + '\n' + log.debug("sending {}".format(command)) + self.socket.sendall(command.encode('utf-8')) + except OSError as e: + raise VirtualBoxError("Lost communication with {host}:{port} :{error}" + .format(host=self._host, port=self._port, error=e)) + + # Now retrieve the result + data = [] + buf = '' + while True: + try: + chunk = self.socket.recv(1024) + buf += chunk.decode("utf-8") + except OSError as e: + raise VirtualBoxError("Communication timed out with {host}:{port} :{error}" + .format(host=self._host, port=self._port, error=e)) + + # If the buffer doesn't end in '\n' then we can't be done + try: + if buf[-1] != '\n': + continue + except IndexError: + raise VirtualBoxError("Could not communicate with {host}:{port}" + .format(host=self._host, port=self._port)) + + data += buf.split('\r\n') + if data[-1] == '': + data.pop() + buf = '' + + if len(data) == 0: + raise VirtualBoxError("no data returned from {host}:{port}" + .format(host=self._host, port=self._port)) + + # Does it contain an error code? + if self.error_re.search(data[-1]): + raise VirtualBoxError(data[-1][4:]) + + # Or does the last line begin with '100-'? Then we are done! + if data[-1][:4] == '100-': + data[-1] = data[-1][4:] + if data[-1] == 'OK': + data.pop() + break + + # Remove success responses codes + for index in range(len(data)): + if self.success_re.search(data[index]): + data[index] = data[index][4:] + + log.debug("returned result {}".format(data)) + return data diff --git a/gns3server/modules/virtualbox/virtualbox_error.py b/gns3server/modules/virtualbox/virtualbox_error.py new file mode 100644 index 00000000..74b05171 --- /dev/null +++ b/gns3server/modules/virtualbox/virtualbox_error.py @@ -0,0 +1,39 @@ +# -*- 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 . + +""" +Custom exceptions for VirtualBox module. +""" + + +class VirtualBoxError(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/gns3server/modules/virtualbox/virtualbox_vm.py b/gns3server/modules/virtualbox/virtualbox_vm.py new file mode 100644 index 00000000..aedc5d8a --- /dev/null +++ b/gns3server/modules/virtualbox/virtualbox_vm.py @@ -0,0 +1,318 @@ +# -*- 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 . + +""" +VirtualBox VM instance. +""" + +import os +import shutil + +from pkg_resources import parse_version +from .virtualbox_error import VirtualBoxError +from .adapters.ethernet_adapter import EthernetAdapter +from .nios.nio_udp import NIO_UDP +from ..attic import find_unused_port + +import logging +log = logging.getLogger(__name__) + + +class VirtualBoxVM(object): + """ + VirtualBox VM implementation. + + :param name: name of this VirtualBox VM + :param working_dir: path to a working directory + :param host: host/address to bind for console and UDP connections + :param vbox_id: VirtalBox VM instance ID + :param console: TCP console port + :param console_start_port_range: TCP console port range start + :param console_end_port_range: TCP console port range end + """ + + _instances = [] + _allocated_console_ports = [] + + def __init__(self, + name, + path, + working_dir, + host="127.0.0.1", + vbox_id=None, + console=None, + console_start_port_range=4512, + console_end_port_range=5000): + + if not vbox_id: + self._id = 0 + for identifier in range(1, 1024): + if identifier not in self._instances: + self._id = identifier + self._instances.append(self._id) + break + + if self._id == 0: + raise VirtualBoxError("Maximum number of VirtualBox VM instances reached") + else: + if vbox_id in self._instances: + raise VirtualBoxError("VirtualBox identifier {} is already used by another VirtualBox VM instance".format(vbox_id)) + self._id = vbox_id + self._instances.append(self._id) + + self._name = name + self._console = console + self._working_dir = None + self._host = host + self._command = [] + self._vboxwrapper_process = None + self._vboxwrapper_stdout_file = "" + self._host = "127.0.0.1" + self._started = False + self._console_start_port_range = console_start_port_range + self._console_end_port_range = console_end_port_range + + # VirtualBox settings + self._ethernet_adapter = EthernetAdapter() # one adapter with 1 Ethernet interface + + working_dir_path = os.path.join(working_dir, "vbox", "vm-{}".format(self._id)) + + if vbox_id and not os.path.isdir(working_dir_path): + raise VirtualBoxError("Working directory {} doesn't exist".format(working_dir_path)) + + # create the device own working directory + self.working_dir = working_dir_path + + if not self._console: + # allocate a console port + try: + self._console = find_unused_port(self._console_start_port_range, + self._console_end_port_range, + self._host, + ignore_ports=self._allocated_console_ports) + except Exception as e: + raise VirtualBoxError(e) + + if self._console in self._allocated_console_ports: + raise VirtualBoxError("Console port {} is already used by another VirtualBox VM".format(console)) + self._allocated_console_ports.append(self._console) + + log.info("VirtualBox VM {name} [id={id}] has been created".format(name=self._name, + id=self._id)) + + def defaults(self): + """ + Returns all the default attribute values for this VirtualBox VM. + + :returns: default values (dictionary) + """ + + vbox_defaults = {"name": self._name, + "console": self._console} + + return vbox_defaults + + @property + def id(self): + """ + Returns the unique ID for this VirtualBox VM. + + :returns: id (integer) + """ + + return self._id + + @classmethod + def reset(cls): + """ + Resets allocated instance list. + """ + + cls._instances.clear() + cls._allocated_console_ports.clear() + + @property + def name(self): + """ + Returns the name of this VirtualBox VM. + + :returns: name + """ + + return self._name + + @name.setter + def name(self, new_name): + """ + Sets the name of this VirtualBox VM. + + :param new_name: name + """ + + log.info("VirtualBox VM {name} [id={id}]: renamed to {new_name}".format(name=self._name, + id=self._id, + new_name=new_name)) + self._name = new_name + + @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 this VirtualBox VM. + + :param working_dir: path to the working directory + """ + + try: + os.makedirs(working_dir) + except FileExistsError: + pass + except OSError as e: + raise VirtualBoxError("Could not create working directory {}: {}".format(working_dir, e)) + + self._working_dir = working_dir + log.info("VirtualBox VM {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) + """ + + if console in self._allocated_console_ports: + raise VirtualBoxError("Console port {} is already used by another VirtualBox VM".format(console)) + + self._allocated_console_ports.remove(self._console) + self._console = console + self._allocated_console_ports.append(self._console) + log.info("VirtualBox VM {name} [id={id}]: console port set to {port}".format(name=self._name, + id=self._id, + port=console)) + + def delete(self): + """ + Deletes this VirtualBox VM. + """ + + self.stop() + if self._id in self._instances: + self._instances.remove(self._id) + + if self.console and self.console in self._allocated_console_ports: + self._allocated_console_ports.remove(self.console) + + log.info("VirtualBox VM {name} [id={id}] has been deleted".format(name=self._name, + id=self._id)) + + def clean_delete(self): + """ + Deletes this VirtualBox VM & all files. + """ + + self.stop() + if self._id in self._instances: + self._instances.remove(self._id) + + if self.console: + self._allocated_console_ports.remove(self.console) + + try: + shutil.rmtree(self._working_dir) + except OSError as e: + log.error("could not delete VirtualBox VM {name} [id={id}]: {error}".format(name=self._name, + id=self._id, + error=e)) + return + + log.info("VirtualBox VM {name} [id={id}] has been deleted (including associated files)".format(name=self._name, + id=self._id)) + + def start(self): + """ + Starts this VirtualBox VM. + """ + + pass + + def stop(self): + """ + Stops this VirtualBox VM. + """ + + pass + +# def port_add_nio_binding(self, port_id, nio): +# """ +# Adds a port NIO binding. +# +# :param port_id: port ID +# :param nio: NIO instance to add to the slot/port +# """ +# +# if not self._ethernet_adapter.port_exists(port_id): +# raise VPCSError("Port {port_id} doesn't exist in adapter {adapter}".format(adapter=self._ethernet_adapter, +# port_id=port_id)) +# +# self._ethernet_adapter.add_nio(port_id, nio) +# log.info("VPCS {name} [id={id}]: {nio} added to port {port_id}".format(name=self._name, +# id=self._id, +# nio=nio, +# port_id=port_id)) + +# def port_remove_nio_binding(self, port_id): +# """ +# Removes a port NIO binding. +# +# :param port_id: port ID +# +# :returns: NIO instance +# """ +# +# if not self._ethernet_adapter.port_exists(port_id): +# raise VPCSError("Port {port_id} doesn't exist in adapter {adapter}".format(adapter=self._ethernet_adapter, +# port_id=port_id)) +# +# nio = self._ethernet_adapter.get_nio(port_id) +# self._ethernet_adapter.remove_nio(port_id) +# log.info("VPCS {name} [id={id}]: {nio} removed from port {port_id}".format(name=self._name, +# id=self._id, +# nio=nio, +# port_id=port_id)) +# return nio diff --git a/gns3server/modules/vpcs/__init__.py b/gns3server/modules/vpcs/__init__.py index 71709016..61de9308 100644 --- a/gns3server/modules/vpcs/__init__.py +++ b/gns3server/modules/vpcs/__init__.py @@ -362,7 +362,7 @@ class VPCS(IModule): self.send_response(response) @IModule.route("vpcs.start") - def vm_start(self, request): + def vpcs_start(self, request): """ Starts a VPCS instance. @@ -392,7 +392,7 @@ class VPCS(IModule): self.send_response(True) @IModule.route("vpcs.stop") - def vm_stop(self, request): + def vpcs_stop(self, request): """ Stops a VPCS instance. @@ -422,7 +422,7 @@ class VPCS(IModule): self.send_response(True) @IModule.route("vpcs.reload") - def vm_reload(self, request): + def vpcs_reload(self, request): """ Reloads a VPCS instance.