#!/usr/bin/env python # # Copyright (C) 2016 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 . import ipaddress import aiohttp import asyncio import async_timeout import socket import json import sys import io from fastapi import HTTPException from aiohttp import web from ..utils import parse_version from ..utils.asyncio import locking from ..controller.controller_error import ( ControllerError, ControllerBadRequestError, ControllerNotFoundError, ControllerForbiddenError, ControllerTimeoutError, ControllerUnauthorizedError, ComputeError, ComputeConflictError ) from ..version import __version__, __version_info__ import logging log = logging.getLogger(__name__) class Compute: """ A GNS3 compute. """ def __init__( self, compute_id, controller=None, protocol="http", host="localhost", port=3080, user=None, password=None, name=None, console_host=None, ssl_context=None, ): self._http_session = None assert controller is not None log.info("Create compute %s", compute_id) # if compute_id is None: # self._id = str(uuid.uuid4()) # else: self._id = compute_id self.protocol = protocol self._console_host = console_host self.host = host self.port = port self._user = None self._password = None self._connected = False self._notifications = None self._closed = False # Close mean we are destroying the compute node self._controller = controller self._set_auth(user, password) self._cpu_usage_percent = 0 self._memory_usage_percent = 0 self._disk_usage_percent = 0 self._last_error = None self._ssl_context = ssl_context self._capabilities = {"version": "", "platform": "", "cpus": 0, "memory": 0, "disk_size": 0, "node_types": []} self.name = name # Cache of interfaces on remote host self._interfaces_cache = None self._connection_failure = 0 def _session(self): if self._http_session is None or self._http_session.closed is True: connector = aiohttp.TCPConnector(force_close=True, ssl_context=self._ssl_context) self._http_session = aiohttp.ClientSession(connector=connector) return self._http_session def _set_auth(self, user, password): """ Set authentication parameters """ if user is None or len(user.strip()) == 0: self._user = None self._password = None self._auth = None else: self._user = user.strip() if password: self._password = password try: self._auth = aiohttp.BasicAuth(self._user, self._password.get_secret_value(), "utf-8") except ValueError as e: log.error(str(e)) else: self._password = None self._auth = aiohttp.BasicAuth(self._user, "") def set_last_error(self, msg): """ Set the last error message for this compute. :param msg: message """ self._last_error = msg async def interfaces(self): """ Get the list of network on compute """ if not self._interfaces_cache: response = await self.get("/network/interfaces") self._interfaces_cache = response.json return self._interfaces_cache async def update(self, **kwargs): for kw in kwargs: if kw not in ("user", "password"): setattr(self, kw, kwargs[kw]) # It's important to set user and password at the same time if "user" in kwargs or "password" in kwargs: self._set_auth(kwargs.get("user", self._user), kwargs.get("password", self._password)) if self._http_session and not self._http_session.closed: await self._http_session.close() self._connected = False self._controller.notification.controller_emit("compute.updated", self.asdict()) self._controller.save() async def close(self): self._connected = False if self._http_session and not self._http_session.closed: await self._http_session.close() try: if self._notifications: await self._notifications except asyncio.CancelledError: pass self._closed = True @property def name(self): """ :returns: Compute name """ return self._name @name.setter def name(self, name): if name is not None: self._name = name else: if self._user: user = self._user # Due to random user generated by 1.4 it's common to have a very long user if len(user) > 14: user = user[:11] + "..." self._name = f"{self._protocol}://{user}@{self._host}:{self._port}" else: self._name = f"{self._protocol}://{self._host}:{self._port}" @property def connected(self): """ :returns: True if compute node is connected """ return self._connected @property def id(self): """ :returns: Compute identifier (string) """ return self._id @property def host(self): """ :returns: Compute host (string) """ return self._host @property def host_ip(self): """ Return the IP associated to the host """ try: return socket.gethostbyname(self._host) except socket.gaierror: return "0.0.0.0" @host.setter def host(self, host): self._host = host if self._console_host is None: self._console_host = host @property def console_host(self): return self._console_host @property def port(self): """ :returns: Compute port (integer) """ return self._port @port.setter def port(self, port): self._port = port @property def protocol(self): """ :returns: Compute protocol (string) """ return self._protocol @protocol.setter def protocol(self, protocol): self._protocol = protocol @property def user(self): return self._user @user.setter def user(self, value): self._set_auth(value, self._password) @property def password(self): return self._password @password.setter def password(self, value): self._set_auth(self._user, value) @property def cpu_usage_percent(self): return self._cpu_usage_percent @property def memory_usage_percent(self): return self._memory_usage_percent @property def disk_usage_percent(self): return self._disk_usage_percent def asdict(self, topology_dump=False): """ :param topology_dump: Filter to keep only properties require for saving on disk """ if topology_dump: return { "compute_id": self._id, "name": self._name, "protocol": self._protocol, "host": self._host, "port": self._port, } return { "compute_id": self._id, "name": self._name, "protocol": self._protocol, "host": self._host, "port": self._port, "user": self._user, "connected": self._connected, "cpu_usage_percent": self._cpu_usage_percent, "memory_usage_percent": self._memory_usage_percent, "disk_usage_percent": self._disk_usage_percent, "capabilities": self._capabilities, "last_error": self._last_error, } async def download_file(self, project, path): """ Read file of a project and download it :param project: A project object :param path: The path of the file in the project :returns: A file stream """ url = self._getUrl(f"/projects/{project.id}/files/{path}") response = await self._session().request("GET", url, auth=self._auth) if response.status == 404: raise ControllerNotFoundError(f"{path} not found on compute") return response async def download_image(self, image_type, image): """ Read file of a project and download it :param image_type: Image type :param image: The path of the image :returns: A file stream """ url = self._getUrl(f"/{image_type}/images/{image}") response = await self._session().request("GET", url, auth=self._auth) if response.status == 404: raise ControllerNotFoundError(f"{image} not found on compute") return response async def http_query(self, method, path, data=None, dont_connect=False, **kwargs): """ :param dont_connect: If true do not reconnect if not connected """ if not self._connected and not dont_connect: if self._id == "vm" and not self._controller.gns3vm.running: await self._controller.gns3vm.start() await self.connect() if not self._connected and not dont_connect: raise ComputeError(f"Cannot connect to compute '{self._name}' with request {method} {path}") response = await self._run_http_query(method, path, data=data, **kwargs) return response async def _try_reconnect(self): """ We catch error during reconnect """ try: await self.connect() except ControllerError: pass @locking async def connect(self, report_failed_connection=False): """ Check if remote server is accessible """ if not self._connected and not self._closed and self.host: try: log.info(f"Connecting to compute '{self._id}'") response = await self._run_http_query("GET", "/capabilities") except ComputeError as e: if report_failed_connection: raise log.warning(f"Cannot connect to compute '{self._id}': {e}") # Try to reconnect after 5 seconds if server unavailable only if not during tests (otherwise we create a ressource usage bomb) if not hasattr(sys, "_called_from_test") or not sys._called_from_test: self._connection_failure += 1 # After 5 failure we close the project using the compute to avoid sync issues if self._connection_failure == 10: log.error(f"Could not connect to compute '{self._id}' after multiple attempts: {e}") await self._controller.close_compute_projects(self) asyncio.get_event_loop().call_later(5, lambda: asyncio.ensure_future(self._try_reconnect())) return except web.HTTPNotFound: raise ControllerNotFoundError(f"The server {self._id} is not a GNS3 server or it's a 1.X server") except web.HTTPUnauthorized: raise ControllerUnauthorizedError(f"Invalid auth for server {self._id}") except web.HTTPServiceUnavailable: raise ControllerNotFoundError(f"The server {self._id} is unavailable") except ValueError: raise ComputeError(f"Invalid server url for server {self._id}") if "version" not in response.json: msg = f"The server {self._id} is not a GNS3 server" log.error(msg) await self._http_session.close() raise ControllerNotFoundError(msg) self._capabilities = response.json if response.json["version"].split("+")[0] != __version__.split("+")[0]: if self._name.startswith("GNS3 VM"): msg = ( "GNS3 version {} is not the same as the GNS3 VM version {}. Please upgrade the GNS3 VM.".format( __version__, response.json["version"] ) ) else: msg = "GNS3 controller version {} is not the same as compute {} version {}".format( __version__, self._name, response.json["version"] ) if __version_info__[3] == 0: # Stable release log.error(msg) await self._http_session.close() self._last_error = msg raise ControllerError(msg) elif parse_version(__version__)[:2] != parse_version(response.json["version"])[:2]: # We don't allow different major version to interact even with dev build log.error(msg) await self._http_session.close() self._last_error = msg raise ControllerError(msg) else: msg = f"{msg}\nUsing different versions may result in unexpected problems. Please use at your own risk." self._controller.notification.controller_emit("log.warning", {"message": msg}) self._notifications = asyncio.gather(self._connect_notification()) self._connected = True self._connection_failure = 0 self._last_error = None self._controller.notification.controller_emit("compute.updated", self.asdict()) async def _connect_notification(self): """ Connect to the notification stream """ ws_url = self._getUrl("/notifications/ws") try: async with self._session().ws_connect(ws_url, auth=self._auth, heartbeat=10) as ws: log.info(f"Connected to compute '{self._id}' WebSocket '{ws_url}'") async for response in ws: if response.type == aiohttp.WSMsgType.TEXT: msg = json.loads(response.data) action = msg.pop("action") event = msg.pop("event") project_id = msg.pop("project_id", None) if action == "ping": self._cpu_usage_percent = event["cpu_usage_percent"] self._memory_usage_percent = event["memory_usage_percent"] self._disk_usage_percent = event["disk_usage_percent"] # FIXME: slow down number of compute events self._controller.notification.controller_emit("compute.updated", self.asdict()) else: if action == "log.error": log.error(event.pop("message")) await self._controller.notification.dispatch( action, event, project_id=project_id, compute_id=self.id ) else: if response.type == aiohttp.WSMsgType.CLOSE: await ws.close() elif response.type == aiohttp.WSMsgType.ERROR: log.error(f"Error received on compute '{self._id}' WebSocket '{ws_url}': {ws.exception()}") elif response.type == aiohttp.WSMsgType.CLOSED: pass break except aiohttp.ClientError as e: log.error(f"Client response error received on compute '{self._id}' WebSocket '{ws_url}': {e}") finally: self._connected = False log.info(f"Connection closed to compute '{self._id}' WebSocket '{ws_url}'") # Try to reconnect after 1 second if server unavailable only if not during tests (otherwise we create a ressources usage bomb) from gns3server.api.server import app if not app.state.exiting and not hasattr(sys, "_called_from_test"): log.info(f"Reconnecting to compute '{self._id}' WebSocket '{ws_url}'") asyncio.get_event_loop().call_later(1, lambda: asyncio.ensure_future(self.connect())) self._cpu_usage_percent = None self._memory_usage_percent = None self._disk_usage_percent = None self._controller.notification.controller_emit("compute.updated", self.asdict()) def _getUrl(self, path): host = self._host # IPV6 if host: # IPV6 if ":" in host: # Reduce IPV6 to his simple form host = str(ipaddress.IPv6Address(host)) if host == "::": host = "::1" host = f"[{host}]" elif host == "0.0.0.0": host = "127.0.0.1" return f"{self._protocol}://{host}:{self._port}/v3/compute{path}" def get_url(self, path): """ Returns URL for specific path at Compute""" return self._getUrl(path) async def _run_http_query(self, method, path, data=None, timeout=20, raw=False): async with async_timeout.timeout(delay=timeout): url = self._getUrl(path) headers = {"content-type": "application/json"} chunked = None if data == {}: data = None elif data is not None: if hasattr(data, "asdict"): data = json.dumps(data.asdict()) elif isinstance(data, aiohttp.streams.EmptyStreamReader): data = None # Stream the request elif isinstance(data, aiohttp.streams.StreamReader) or isinstance(data, bytes): chunked = True headers["content-type"] = "application/octet-stream" # If the data is an open file we will iterate on it elif isinstance(data, io.BufferedIOBase): chunked = True headers["content-type"] = "application/octet-stream" else: data = json.dumps(data).encode("utf-8") try: log.debug(f"Attempting request to compute: {method} {url} {headers}") response = await self._session().request( method, url, headers=headers, data=data, auth=self._auth, chunked=chunked, timeout=timeout ) except asyncio.TimeoutError: raise ComputeError(f"Timeout error for {method} call to {url} after {timeout}s") except ( aiohttp.ClientError, aiohttp.ServerDisconnectedError, aiohttp.ClientResponseError, ValueError, KeyError, socket.gaierror, ) as e: # aiohttp 2.3.1 raises socket.gaierror when cannot find host raise ComputeError(str(e)) body = await response.read() if body and not raw: body = body.decode() if response.status >= 300: # Try to decode the GNS3 error if body and not raw: try: msg = json.loads(body)["message"] except (KeyError, ValueError): msg = body else: msg = "" if response.status == 400: raise ControllerBadRequestError(msg) elif response.status == 401: raise ControllerUnauthorizedError(f"Invalid authentication for compute '{self.name}' [{self.id}]") elif response.status == 403: raise ControllerForbiddenError(msg) elif response.status == 404: raise ControllerNotFoundError(f"{method} {path} not found") elif response.status == 408 or response.status == 504: raise ControllerTimeoutError(f"{method} {path} request timeout") elif response.status == 409: try: raise ComputeConflictError(url, json.loads(body)) # If the 409 doesn't come from a GNS3 server except ValueError: raise ControllerError(msg) else: raise HTTPException( status_code=response.status, detail=f"HTTP error {response.status} received from compute " f"'{self.name}' for request {method} {path}: {msg}" ) if body and len(body): if raw: response.body = body else: try: response.json = json.loads(body) except ValueError: raise ControllerError(f"The server {self._id} is not a GNS3 server") else: response.json = {} response.body = b"" return response async def get(self, path, **kwargs): return await self.http_query("GET", path, **kwargs) async def post(self, path, data={}, **kwargs): response = await self.http_query("POST", path, data, **kwargs) return response async def put(self, path, data={}, **kwargs): response = await self.http_query("PUT", path, data, **kwargs) return response async def delete(self, path, **kwargs): return await self.http_query("DELETE", path, **kwargs) async def forward(self, method, type, path, data=None): """ Forward a call to the emulator on compute """ try: action = f"/{type}/{path}" res = await self.http_query(method, action, data=data, timeout=None) except aiohttp.ServerDisconnectedError: raise ControllerError(f"Connection lost to {self._id} during {method} {action}") return res.json async def list_files(self, project): """ List files in the project on computes """ path = f"/projects/{project.id}/files" res = await self.http_query("GET", path, timeout=None) return res.json async def get_ip_on_same_subnet(self, other_compute): """ Try to find the best ip for communication from one compute to another :returns: Tuple (ip_for_this_compute, ip_for_other_compute) """ if other_compute == self: return self.host_ip, self.host_ip # Perhaps the user has correct network gateway, we trust him if self.host_ip not in ("0.0.0.0", "127.0.0.1") and other_compute.host_ip not in ("0.0.0.0", "127.0.0.1"): return self.host_ip, other_compute.host_ip this_compute_interfaces = await self.interfaces() other_compute_interfaces = await other_compute.interfaces() # Sort interface to put the compute host in first position # we guess that if user specified this host it could have a reason (VMware Nat / Host only interface) this_compute_interfaces = sorted(this_compute_interfaces, key=lambda i: i["ip_address"] != self.host_ip) other_compute_interfaces = sorted( other_compute_interfaces, key=lambda i: i["ip_address"] != other_compute.host_ip ) for this_interface in this_compute_interfaces: # Skip if no ip or no netmask (vbox when stopped set a null netmask) if len(this_interface["ip_address"]) == 0 or this_interface["netmask"] is None: continue # Ignore 169.254 network because it's for Windows special purpose if this_interface["ip_address"].startswith("169.254."): continue this_network = ipaddress.ip_network( "{}/{}".format(this_interface["ip_address"], this_interface["netmask"]), strict=False ) for other_interface in other_compute_interfaces: if len(other_interface["ip_address"]) == 0 or other_interface["netmask"] is None: continue # Avoid stuff like 127.0.0.1 if other_interface["ip_address"] == this_interface["ip_address"]: continue other_network = ipaddress.ip_network( "{}/{}".format(other_interface["ip_address"], other_interface["netmask"]), strict=False ) if this_network.overlaps(other_network): return this_interface["ip_address"], other_interface["ip_address"] raise ValueError(f"No common subnet for compute {self.name} and {other_compute.name}")