From 08423eff96da9d2af605df9221b3a70990db6cfc Mon Sep 17 00:00:00 2001 From: Julien Duponchelle Date: Fri, 30 Jun 2017 10:22:30 +0200 Subject: [PATCH 1/3] Support packet filtering for VPCS https://github.com/GNS3/gns3-gui/issues/765 --- .gitignore | 3 +- docs/curl.rst | 9 + docs/glossary.rst | 5 + gns3server/compute/base_manager.py | 3 +- gns3server/compute/base_node.py | 24 ++ gns3server/compute/nios/nio_udp.py | 14 +- gns3server/compute/vpcs/vpcs_vm.py | 14 ++ gns3server/controller/link.py | 123 +++++++++- gns3server/controller/project.py | 2 + gns3server/controller/udp_link.py | 33 ++- .../handlers/api/compute/vpcs_handler.py | 27 ++ .../handlers/api/controller/link_handler.py | 23 +- gns3server/handlers/api/vpcs_handler.py | 230 ------------------ gns3server/schemas/filter.py | 23 ++ gns3server/schemas/link.py | 4 +- gns3server/schemas/nio.py | 5 +- gns3server/ubridge/hypervisor.py | 4 +- tests/compute/builtin/nodes/test_cloud.py | 2 +- .../compute/dynamips/test_ethernet_switch.py | 4 +- tests/compute/test_base_node.py | 26 +- tests/controller/test_link.py | 50 +++- tests/controller/test_project.py | 2 +- tests/controller/test_udp_link.py | 89 ++++++- tests/handlers/api/compute/test_vpcs.py | 14 ++ tests/handlers/api/controller/test_link.py | 62 +++-- 25 files changed, 526 insertions(+), 269 deletions(-) delete mode 100644 gns3server/handlers/api/vpcs_handler.py create mode 100644 gns3server/schemas/filter.py diff --git a/.gitignore b/.gitignore index c1434d71..385ea50d 100644 --- a/.gitignore +++ b/.gitignore @@ -55,4 +55,5 @@ startup.vpcs .gns3_shell_history # Virtualenv -env \ No newline at end of file +env.ropeproject +.ropeproject diff --git a/docs/curl.rst b/docs/curl.rst index ea92f409..02cc2b04 100644 --- a/docs/curl.rst +++ b/docs/curl.rst @@ -222,6 +222,15 @@ This will display a red square in the middle of your topologies: Tips: you can embed png/jpg... by using a base64 encoding in the SVG. +Add filter to the link +###################### + +Filter allow you to add error on a link. + +.. code-block:: shell-session + curl -X PUT "http://localhost:3080/v2/projects/b8c070f7-f34c-4b7b-ba6f-be3d26ed073f/links/007f2177-6790-4e1b-ac28-41fa226b2a06" -d '{"filters": {"frequency_drop": [5]}}' + + Creation of nodes ################# diff --git a/docs/glossary.rst b/docs/glossary.rst index 3da68aea..9b25df9f 100644 --- a/docs/glossary.rst +++ b/docs/glossary.rst @@ -62,3 +62,8 @@ Symbol are the icon used for nodes. Scene ----- The drawing area + + +Filter +------ +Packet filter this allow to add latency or packet drop. diff --git a/gns3server/compute/base_manager.py b/gns3server/compute/base_manager.py index 18dffa26..0885db90 100644 --- a/gns3server/compute/base_manager.py +++ b/gns3server/compute/base_manager.py @@ -373,7 +373,8 @@ class BaseManager: sock.connect(sa) except OSError as e: raise aiohttp.web.HTTPInternalServerError(text="Could not create an UDP connection to {}:{}: {}".format(rhost, rport, e)) - nio = NIOUDP(lport, rhost, rport) + filters = nio_settings.get("filters", []) + nio = NIOUDP(lport, rhost, rport, filters) elif nio_settings["type"] == "nio_tap": tap_device = nio_settings["tap_device"] # if not is_interface_up(tap_device): diff --git a/gns3server/compute/base_node.py b/gns3server/compute/base_node.py index 1afa05cf..5f1ba36a 100644 --- a/gns3server/compute/base_node.py +++ b/gns3server/compute/base_node.py @@ -586,6 +586,30 @@ class BaseNode: pcap_file=destination_nio.pcap_output_file)) yield from self._ubridge_send('bridge start {name}'.format(name=bridge_name)) + yield from self._ubridge_apply_filters(bridge_name, destination_nio.filters) + + @asyncio.coroutine + def _update_ubridge_udp_connection(self, bridge_name, source_nio, destination_nio): + yield from self._ubridge_apply_filters(bridge_name, destination_nio.filters) + + @asyncio.coroutine + def _ubridge_apply_filters(self, bridge_name, filters): + """ + Apply filter like rate limiting + + :param bridge_name: bridge name in uBridge + :param filters: Array of filter dictionnary + """ + yield from self._ubridge_send('bridge reset_packet_filters ' + bridge_name) + i = 0 + for (type, values) in filters.items(): + cmd = "bridge add_packet_filter {bridge_name} {filter_name} {filter_type} {filter_value}".format( + bridge_name=bridge_name, + filter_name="filter" + str(i), + filter_type=type, + filter_value=" ".join([str(v) for v in values])) + yield from self._ubridge_send(cmd) + i += 1 @asyncio.coroutine def _add_ubridge_ethernet_connection(self, bridge_name, ethernet_interface, block_host_traffic=True): diff --git a/gns3server/compute/nios/nio_udp.py b/gns3server/compute/nios/nio_udp.py index a87875fe..96811a94 100644 --- a/gns3server/compute/nios/nio_udp.py +++ b/gns3server/compute/nios/nio_udp.py @@ -32,12 +32,24 @@ class NIOUDP(NIO): :param rport: remote port number """ - def __init__(self, lport, rhost, rport): + def __init__(self, lport, rhost, rport, filters): super().__init__() self._lport = lport self._rhost = rhost self._rport = rport + self._filters = filters + + @property + def filters(self): + """ + Return the list of filter on this NIO + """ + return self._filters + + @filters.setter + def filters(self, val): + self._filters = val @property def lport(self): diff --git a/gns3server/compute/vpcs/vpcs_vm.py b/gns3server/compute/vpcs/vpcs_vm.py index 3bc90264..91f7c677 100644 --- a/gns3server/compute/vpcs/vpcs_vm.py +++ b/gns3server/compute/vpcs/vpcs_vm.py @@ -73,6 +73,10 @@ class VPCSVM(BaseNode): self.startup_script = startup_script self._ethernet_adapter = EthernetAdapter() # one adapter with 1 Ethernet interface + @property + def ethernet_adapter(self): + return self._ethernet_adapter + @asyncio.coroutine def close(self): """ @@ -387,6 +391,16 @@ class VPCSVM(BaseNode): return nio + @asyncio.coroutine + def port_update_nio_binding(self, port_number, nio): + if not self._ethernet_adapter.port_exists(port_number): + raise VPCSError("Port {port_number} doesn't exist in adapter {adapter}".format(adapter=self._ethernet_adapter, + port_number=port_number)) + if self.ubridge: + yield from self._update_ubridge_udp_connection("VPCS-{}".format(self._id), self._local_udp_tunnel[1], nio) + elif self.is_running(): + raise VPCSError("Sorry, adding a link to a started VPCS instance is not supported without using uBridge.") + @asyncio.coroutine def port_remove_nio_binding(self, port_number): """ diff --git a/gns3server/controller/link.py b/gns3server/controller/link.py index 90ec12fc..b36b30cb 100644 --- a/gns3server/controller/link.py +++ b/gns3server/controller/link.py @@ -26,6 +26,68 @@ import logging log = logging.getLogger(__name__) +FILTERS = [ + { + "type": "frequency_drop", + "name": "Frequency drop", + "description": "It will drop everything with a -1 frequency, drop every Nth packet with a positive frequency, or drop nothing", + "parameters": [ + { + "name": "Frequency", + "minimum": -1, + "maximum": 32767, + "unit": "th packet" + } + ] + }, + { + "type": "packet_loss", + "name": "Packet loss", + "description": "The percentage represents the chance for a packet to be lost", + "parameters": [ + { + "name": "Frequency", + "minimum": 0, + "maximum": 100, + "unit": "%" + } + ] + }, + { + "type": "delay", + "name": "Delay", + "description": "Delay packets in milliseconds. You can add jitter in milliseconds (+/-) of the delay", + "parameters": [ + { + "name": "Delay", + "minimum": 0, + "maximum": 32767, + "unit": "ms" + }, + { + "name": "Jitter", + "minimum": 0, + "maximum": 32767, + "unit": "ms" + } + ] + }, + { + "type": "corrupt", + "name": "Corrupt", + "description": "The percentage represents the chance for a packet to be corrupt", + "parameters": [ + { + "name": "Frequency", + "minimum": 0, + "maximum": 100, + "unit": "%" + } + ] + } +] + + class Link: """ Base class for links. @@ -44,6 +106,32 @@ class Link: self._streaming_pcap = None self._created = False self._link_type = "ethernet" + self._filters = {} + + @property + def filters(self): + """ + Get an array of filters + """ + return self._filters + + @asyncio.coroutine + def update_filters(self, filters): + """ + Modify the filters list. + + Filter with value 0 will be dropped because not active + """ + new_filters = {} + for (filter, values) in filters.items(): + values = [int(v) for v in values] + if len(values) != 0 and values[0] != 0: + new_filters[filter] = values + + if new_filters != self.filters: + self._filters = new_filters + if self._created: + yield from self.update() @property def created(self): @@ -127,6 +215,13 @@ class Link: raise NotImplementedError + @asyncio.coroutine + def update(self): + """ + Update a link + """ + raise NotImplementedError + @asyncio.coroutine def delete(self): """ @@ -230,6 +325,28 @@ class Link: else: return None + def available_filters(self): + """ + Return the list of filters compatible with this link + + :returns: Array of filters + """ + filter_node = self._get_filter_node() + if filter_node: + return FILTERS + return [] + + def _get_filter_node(self): + """ + Return the node where the filter will run + + :returns: None if no node support filtering else the node + """ + for node in self._nodes: + if node["node"].node_type in ('vpcs', ): + return node["node"] + return None + def __eq__(self, other): if not isinstance(other, Link): return False @@ -253,7 +370,8 @@ class Link: if topology_dump: return { "nodes": res, - "link_id": self._id + "link_id": self._id, + "filters": self._filters } return { "nodes": res, @@ -262,5 +380,6 @@ class Link: "capturing": self._capturing, "capture_file_name": self._capture_file_name, "capture_file_path": self.capture_file_path, - "link_type": self._link_type + "link_type": self._link_type, + "filters": self._filters } diff --git a/gns3server/controller/project.py b/gns3server/controller/project.py index 8d25f024..973a6cbd 100644 --- a/gns3server/controller/project.py +++ b/gns3server/controller/project.py @@ -665,6 +665,8 @@ class Project: for node_link in link_data["nodes"]: node = self.get_node(node_link["node_id"]) yield from link.add_node(node, node_link["adapter_number"], node_link["port_number"], label=node_link.get("label"), dump=False) + if "filters" in link_data: + yield from link.update_filters(link_data["filters"]) for drawing_data in topology.get("drawings", []): yield from self.add_drawing(dump=False, **drawing_data) diff --git a/gns3server/controller/udp_link.py b/gns3server/controller/udp_link.py index 5b87ec24..798f97c5 100644 --- a/gns3server/controller/udp_link.py +++ b/gns3server/controller/udp_link.py @@ -62,12 +62,21 @@ class UDPLink(Link): response = yield from node2.compute.post("/projects/{}/ports/udp".format(self._project.id)) self._node2_port = response.json["udp_port"] + node1_filters = {} + node2_filters = {} + filter_node = self._get_filter_node() + if filter_node == node1: + node1_filters = self._filters + elif filter_node == node2: + node2_filters = self._filters + # Create the tunnel on both side self._link_data.append({ "lport": self._node1_port, "rhost": node2_host, "rport": self._node2_port, - "type": "nio_udp" + "type": "nio_udp", + "filters": node1_filters }) yield from node1.post("/adapters/{adapter_number}/ports/{port_number}/nio".format(adapter_number=adapter_number1, port_number=port_number1), data=self._link_data[0], timeout=120) @@ -75,7 +84,8 @@ class UDPLink(Link): "lport": self._node2_port, "rhost": node1_host, "rport": self._node1_port, - "type": "nio_udp" + "type": "nio_udp", + "filters": node2_filters }) try: yield from node2.post("/adapters/{adapter_number}/ports/{port_number}/nio".format(adapter_number=adapter_number2, port_number=port_number2), data=self._link_data[1], timeout=120) @@ -85,6 +95,25 @@ class UDPLink(Link): raise e self._created = True + @asyncio.coroutine + def update(self): + if len(self._link_data) == 0: + return + node1 = self._nodes[0]["node"] + node2 = self._nodes[1]["node"] + filter_node = self._get_filter_node() + + if node1 == filter_node: + adapter_number1 = self._nodes[0]["adapter_number"] + port_number1 = self._nodes[0]["port_number"] + self._link_data[0]["filters"] = self._filters + yield from node1.put("/adapters/{adapter_number}/ports/{port_number}/nio".format(adapter_number=adapter_number1, port_number=port_number1), data=self._link_data[0], timeout=120) + elif node2 == filter_node: + adapter_number2 = self._nodes[1]["adapter_number"] + port_number2 = self._nodes[1]["port_number"] + self._link_data[1]["filters"] = self._filters + yield from node2.put("/adapters/{adapter_number}/ports/{port_number}/nio".format(adapter_number=adapter_number2, port_number=port_number2), data=self._link_data[1], timeout=221) + @asyncio.coroutine def delete(self): """ diff --git a/gns3server/handlers/api/compute/vpcs_handler.py b/gns3server/handlers/api/compute/vpcs_handler.py index d4096adf..cfd1a210 100644 --- a/gns3server/handlers/api/compute/vpcs_handler.py +++ b/gns3server/handlers/api/compute/vpcs_handler.py @@ -224,6 +224,33 @@ class VPCSHandler: response.set_status(201) response.json(nio) + @Route.put( + r"/projects/{project_id}/vpcs/nodes/{node_id}/adapters/{adapter_number:\d+}/ports/{port_number:\d+}/nio", + parameters={ + "project_id": "Project UUID", + "node_id": "Node UUID", + "adapter_number": "Network adapter where the nio is located", + "port_number": "Port from where the nio should be updated" + }, + status_codes={ + 201: "NIO updated", + 400: "Invalid request", + 404: "Instance doesn't exist" + }, + input=NIO_SCHEMA, + output=NIO_SCHEMA, + description="Update a NIO from a VPCS instance") + def update_nio(request, response): + + vpcs_manager = VPCS.instance() + vm = vpcs_manager.get_node(request.match_info["node_id"], project_id=request.match_info["project_id"]) + nio = vm.ethernet_adapter.get_nio(int(request.match_info["port_number"])) + if "filters" in request.json and nio: + nio.filters = request.json["filters"] + yield from vm.port_update_nio_binding(int(request.match_info["port_number"]), nio) + response.set_status(201) + response.json(request.json) + @Route.delete( r"/projects/{project_id}/vpcs/nodes/{node_id}/adapters/{adapter_number:\d+}/ports/{port_number:\d+}/nio", parameters={ diff --git a/gns3server/handlers/api/controller/link_handler.py b/gns3server/handlers/api/controller/link_handler.py index 4854ad90..df229e01 100644 --- a/gns3server/handlers/api/controller/link_handler.py +++ b/gns3server/handlers/api/controller/link_handler.py @@ -62,6 +62,7 @@ class LinkHandler: project = yield from Controller.instance().get_loaded_project(request.match_info["project_id"]) link = yield from project.add_link() + yield from link.update_filters(request.json.get("filters", {})) try: for node in request.json["nodes"]: yield from link.add_node(project.get_node(node["node_id"]), @@ -74,6 +75,24 @@ class LinkHandler: response.set_status(201) response.json(link) + @Route.get( + r"/projects/{project_id}/links/{link_id}/available_filters", + parameters={ + "project_id": "Project UUID", + "link_id": "Link UUID" + }, + status_codes={ + 200: "List of filters", + 400: "Invalid request" + }, + description="Return the list of filters available for this link") + def list_filters(request, response): + + project = yield from Controller.instance().get_loaded_project(request.match_info["project_id"]) + link = project.get_link(request.match_info["link_id"]) + response.set_status(200) + response.json(link.available_filters()) + @Route.put( r"/projects/{project_id}/links/{link_id}", parameters={ @@ -91,7 +110,9 @@ class LinkHandler: project = yield from Controller.instance().get_loaded_project(request.match_info["project_id"]) link = project.get_link(request.match_info["link_id"]) - yield from link.update_nodes(request.json["nodes"]) + yield from link.update_filters(request.json.get("filters", {})) + if "nodes" in request.json: + yield from link.update_nodes(request.json["nodes"]) response.set_status(201) response.json(link) diff --git a/gns3server/handlers/api/vpcs_handler.py b/gns3server/handlers/api/vpcs_handler.py deleted file mode 100644 index 6af82ad7..00000000 --- a/gns3server/handlers/api/vpcs_handler.py +++ /dev/null @@ -1,230 +0,0 @@ -# -*- coding: utf-8 -*- -# -# Copyright (C) 2015 GNS3 Technologies Inc. -# -# This program is free software: you can redistribute it and/or modify -# it under the terms of the GNU General Public License as published by -# the Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU General Public License for more details. -# -# You should have received a copy of the GNU General Public License -# along with this program. If not, see . - -from aiohttp.web import HTTPConflict -from ...web.route import Route -from ...schemas.nio import NIO_SCHEMA -from ...schemas.vpcs import VPCS_CREATE_SCHEMA -from ...schemas.vpcs import VPCS_UPDATE_SCHEMA -from ...schemas.vpcs import VPCS_OBJECT_SCHEMA -from ...modules.vpcs import VPCS - - -class VPCSHandler: - - """ - API entry points for VPCS. - """ - - @classmethod - @Route.post( - r"/projects/{project_id}/vpcs/vms", - parameters={ - "project_id": "UUID for the project" - }, - status_codes={ - 201: "Instance created", - 400: "Invalid request", - 409: "Conflict" - }, - description="Create a new VPCS instance", - input=VPCS_CREATE_SCHEMA, - output=VPCS_OBJECT_SCHEMA) - def create(request, response): - - vpcs = VPCS.instance() - vm = yield from vpcs.create_vm(request.json["name"], - request.match_info["project_id"], - request.json.get("vm_id"), - console=request.json.get("console"), - startup_script=request.json.get("startup_script")) - response.set_status(201) - response.json(vm) - - @classmethod - @Route.get( - r"/projects/{project_id}/vpcs/vms/{vm_id}", - parameters={ - "project_id": "UUID for the project", - "vm_id": "UUID for the instance" - }, - status_codes={ - 200: "Success", - 400: "Invalid request", - 404: "Instance doesn't exist" - }, - description="Get a VPCS instance", - output=VPCS_OBJECT_SCHEMA) - def show(request, response): - - vpcs_manager = VPCS.instance() - vm = vpcs_manager.get_vm(request.match_info["vm_id"], project_id=request.match_info["project_id"]) - response.json(vm) - - @classmethod - @Route.put( - r"/projects/{project_id}/vpcs/vms/{vm_id}", - parameters={ - "project_id": "UUID for the project", - "vm_id": "UUID for the instance" - }, - status_codes={ - 200: "Instance updated", - 400: "Invalid request", - 404: "Instance doesn't exist", - 409: "Conflict" - }, - description="Update a VPCS instance", - input=VPCS_UPDATE_SCHEMA, - output=VPCS_OBJECT_SCHEMA) - def update(request, response): - - vpcs_manager = VPCS.instance() - vm = vpcs_manager.get_vm(request.match_info["vm_id"], project_id=request.match_info["project_id"]) - vm.name = request.json.get("name", vm.name) - vm.console = request.json.get("console", vm.console) - vm.startup_script = request.json.get("startup_script", vm.startup_script) - response.json(vm) - - @classmethod - @Route.delete( - r"/projects/{project_id}/vpcs/vms/{vm_id}", - parameters={ - "project_id": "UUID for the project", - "vm_id": "UUID for the instance" - }, - status_codes={ - 204: "Instance deleted", - 400: "Invalid request", - 404: "Instance doesn't exist" - }, - description="Delete a VPCS instance") - def delete(request, response): - - yield from VPCS.instance().delete_vm(request.match_info["vm_id"]) - response.set_status(204) - - @classmethod - @Route.post( - r"/projects/{project_id}/vpcs/vms/{vm_id}/start", - parameters={ - "project_id": "UUID for the project", - "vm_id": "UUID for the instance" - }, - status_codes={ - 204: "Instance started", - 400: "Invalid request", - 404: "Instance doesn't exist" - }, - description="Start a VPCS instance", - output=VPCS_OBJECT_SCHEMA) - def start(request, response): - - vpcs_manager = VPCS.instance() - vm = vpcs_manager.get_vm(request.match_info["vm_id"], project_id=request.match_info["project_id"]) - yield from vm.start() - response.json(vm) - - @classmethod - @Route.post( - r"/projects/{project_id}/vpcs/vms/{vm_id}/stop", - parameters={ - "project_id": "UUID for the project", - "vm_id": "UUID for the instance" - }, - status_codes={ - 204: "Instance stopped", - 400: "Invalid request", - 404: "Instance doesn't exist" - }, - description="Stop a VPCS instance") - def stop(request, response): - - vpcs_manager = VPCS.instance() - vm = vpcs_manager.get_vm(request.match_info["vm_id"], project_id=request.match_info["project_id"]) - yield from vm.stop() - response.set_status(204) - - @classmethod - @Route.post( - r"/projects/{project_id}/vpcs/vms/{vm_id}/reload", - parameters={ - "project_id": "UUID for the project", - "vm_id": "UUID for the instance", - }, - status_codes={ - 204: "Instance reloaded", - 400: "Invalid request", - 404: "Instance doesn't exist" - }, - description="Reload a VPCS instance") - def reload(request, response): - - vpcs_manager = VPCS.instance() - vm = vpcs_manager.get_vm(request.match_info["vm_id"], project_id=request.match_info["project_id"]) - yield from vm.reload() - response.set_status(204) - - @Route.post( - r"/projects/{project_id}/vpcs/vms/{vm_id}/adapters/{adapter_number:\d+}/ports/{port_number:\d+}/nio", - parameters={ - "project_id": "UUID for the project", - "vm_id": "UUID for the instance", - "adapter_number": "Network adapter where the nio is located", - "port_number": "Port where the nio should be added" - }, - status_codes={ - 201: "NIO created", - 400: "Invalid request", - 404: "Instance doesn't exist" - }, - description="Add a NIO to a VPCS instance", - input=NIO_SCHEMA, - output=NIO_SCHEMA) - def create_nio(request, response): - - vpcs_manager = VPCS.instance() - vm = vpcs_manager.get_vm(request.match_info["vm_id"], project_id=request.match_info["project_id"]) - nio_type = request.json["type"] - if nio_type not in ("nio_udp", "nio_tap"): - raise HTTPConflict(text="NIO of type {} is not supported".format(nio_type)) - nio = vpcs_manager.create_nio(vm.vpcs_path(), request.json) - vm.port_add_nio_binding(int(request.match_info["port_number"]), nio) - response.set_status(201) - response.json(nio) - - @classmethod - @Route.delete( - r"/projects/{project_id}/vpcs/vms/{vm_id}/adapters/{adapter_number:\d+}/ports/{port_number:\d+}/nio", - parameters={ - "project_id": "UUID for the project", - "vm_id": "UUID for the instance", - "adapter_number": "Network adapter where the nio is located", - "port_number": "Port from where the nio should be removed" - }, - status_codes={ - 204: "NIO deleted", - 400: "Invalid request", - 404: "Instance doesn't exist" - }, - description="Remove a NIO from a VPCS instance") - def delete_nio(request, response): - - vpcs_manager = VPCS.instance() - vm = vpcs_manager.get_vm(request.match_info["vm_id"], project_id=request.match_info["project_id"]) - vm.port_remove_nio_binding(int(request.match_info["port_number"])) - response.set_status(204) diff --git a/gns3server/schemas/filter.py b/gns3server/schemas/filter.py new file mode 100644 index 00000000..d7f09972 --- /dev/null +++ b/gns3server/schemas/filter.py @@ -0,0 +1,23 @@ +#!/usr/bin/env python +# +# Copyright (C) 2017 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 . + + +FILTER_OBJECT_SCHEMA = { + "$schema": "http://json-schema.org/draft-04/schema#", + "description": "Packet filter. This allow to simulate latency and errors", + "type": "object" +} diff --git a/gns3server/schemas/link.py b/gns3server/schemas/link.py index 877c4a89..d3e66dee 100644 --- a/gns3server/schemas/link.py +++ b/gns3server/schemas/link.py @@ -16,7 +16,7 @@ # along with this program. If not, see . from .label import LABEL_OBJECT_SCHEMA - +from .filter import FILTER_OBJECT_SCHEMA LINK_OBJECT_SCHEMA = { "$schema": "http://json-schema.org/draft-04/schema#", @@ -64,6 +64,7 @@ LINK_OBJECT_SCHEMA = { "additionalProperties": False } }, + "filters": FILTER_OBJECT_SCHEMA, "capturing": { "description": "Read only property. True if a capture running on the link", "type": "boolean" @@ -81,7 +82,6 @@ LINK_OBJECT_SCHEMA = { "enum": ["ethernet", "serial"] } }, - "required": ["nodes"], "additionalProperties": False } diff --git a/gns3server/schemas/nio.py b/gns3server/schemas/nio.py index 5efe4840..e45211a7 100644 --- a/gns3server/schemas/nio.py +++ b/gns3server/schemas/nio.py @@ -15,6 +15,8 @@ # You should have received a copy of the GNU General Public License # along with this program. If not, see . +from .filter import FILTER_OBJECT_SCHEMA + NIO_SCHEMA = { "$schema": "http://json-schema.org/draft-04/schema#", @@ -43,7 +45,8 @@ NIO_SCHEMA = { "type": "integer", "minimum": 1, "maximum": 65535 - } + }, + "filters": FILTER_OBJECT_SCHEMA }, "required": ["type", "lport", "rhost", "rport"], "additionalProperties": False diff --git a/gns3server/ubridge/hypervisor.py b/gns3server/ubridge/hypervisor.py index 9deb7b83..a7b1123c 100644 --- a/gns3server/ubridge/hypervisor.py +++ b/gns3server/ubridge/hypervisor.py @@ -138,8 +138,8 @@ class Hypervisor(UBridgeHypervisor): match = re.search("ubridge version ([0-9a-z\.]+)", output) if match: self._version = match.group(1) - if parse_version(self._version) < parse_version("0.9.7"): - raise UbridgeError("uBridge executable version must be >= 0.9.7") + if parse_version(self._version) < parse_version("0.9.12"): + raise UbridgeError("uBridge executable version must be >= 0.9.12") else: raise UbridgeError("Could not determine uBridge version for {}".format(self._path)) except (OSError, subprocess.SubprocessError) as e: diff --git a/tests/compute/builtin/nodes/test_cloud.py b/tests/compute/builtin/nodes/test_cloud.py index 43fee80d..f8eb4a9a 100644 --- a/tests/compute/builtin/nodes/test_cloud.py +++ b/tests/compute/builtin/nodes/test_cloud.py @@ -26,7 +26,7 @@ from tests.utils import asyncio_patch @pytest.fixture def nio(): - return NIOUDP(4242, "127.0.0.1", 4343) + return NIOUDP(4242, "127.0.0.1", 4343, []) @pytest.fixture diff --git a/tests/compute/dynamips/test_ethernet_switch.py b/tests/compute/dynamips/test_ethernet_switch.py index 1520a4be..e316da7f 100644 --- a/tests/compute/dynamips/test_ethernet_switch.py +++ b/tests/compute/dynamips/test_ethernet_switch.py @@ -24,9 +24,9 @@ def test_arp_command(async_run): node = AsyncioMagicMock() node.name = "Test" node.nios = {} - node.nios[0] = NIOUDP(55, "127.0.0.1", 56) + node.nios[0] = NIOUDP(55, "127.0.0.1", 56, []) node.nios[0].name = "Ethernet0" - node.nios[1] = NIOUDP(55, "127.0.0.1", 56) + node.nios[1] = NIOUDP(55, "127.0.0.1", 56, []) node.nios[1].name = "Ethernet1" node._hypervisor.send = AsyncioMagicMock(return_value=["0050.7966.6801 1 Ethernet0", "0050.7966.6802 1 Ethernet1"]) console = EthernetSwitchConsole(node) diff --git a/tests/compute/test_base_node.py b/tests/compute/test_base_node.py index 20a3ee06..e42f3e98 100644 --- a/tests/compute/test_base_node.py +++ b/tests/compute/test_base_node.py @@ -19,7 +19,7 @@ import pytest import aiohttp import asyncio import os -from tests.utils import asyncio_patch +from tests.utils import asyncio_patch, AsyncioMagicMock from unittest.mock import patch, MagicMock @@ -28,6 +28,7 @@ from gns3server.compute.docker.docker_vm import DockerVM from gns3server.compute.vpcs.vpcs_error import VPCSError from gns3server.compute.error import NodeError from gns3server.compute.vpcs import VPCS +from gns3server.compute.nios.nio_udp import NIOUDP @pytest.fixture(scope="function") @@ -121,3 +122,26 @@ def test_change_aux_port(node, port_manager): node.aux = port2 assert node.aux == port2 port_manager.reserve_tcp_port(port1, node.project) + + +def test_update_ubridge_udp_connection(node, async_run): + filters = [{ + "type": "latency", + "value": 10 + }] + + snio = NIOUDP(1245, "localhost", 1246, []) + dnio = NIOUDP(1245, "localhost", 1244, filters) + with asyncio_patch("gns3server.compute.base_node.BaseNode._ubridge_apply_filters") as mock: + async_run(node._update_ubridge_udp_connection('VPCS-10', snio, dnio)) + mock.assert_called_with("VPCS-10", filters) + + +def test_ubridge_apply_filters(node, async_run): + filters = { + "latency": [10] + } + node._ubridge_send = AsyncioMagicMock() + async_run(node._ubridge_apply_filters("VPCS-10", filters)) + node._ubridge_send.assert_any_call("bridge reset_packet_filters VPCS-10") + node._ubridge_send.assert_any_call("bridge add_packet_filter VPCS-10 filter0 latency 10") diff --git a/tests/controller/test_link.py b/tests/controller/test_link.py index 40022544..24638fdb 100644 --- a/tests/controller/test_link.py +++ b/tests/controller/test_link.py @@ -230,6 +230,7 @@ def test_json(async_run, project, compute, link): } } ], + "filters": {}, "link_type": "ethernet", "capturing": False, "capture_file_name": None, @@ -262,7 +263,8 @@ def test_json(async_run, project, compute, link): 'style': 'font-size: 10; font-style: Verdana' } } - ] + ], + "filters": {} } @@ -348,3 +350,49 @@ def test_delete(async_run, project, compute): async_run(link.delete()) assert link not in node2.link + + +def test_update_filters(async_run, project, compute): + node1 = Node(project, compute, "node1", node_type="qemu") + node1._ports = [EthernetPort("E0", 0, 0, 4)] + + link = Link(project) + link.create = AsyncioMagicMock() + link._project.controller.notification.emit = MagicMock() + project.dump = AsyncioMagicMock() + async_run(link.add_node(node1, 0, 4)) + + node2 = Node(project, compute, "node2", node_type="qemu") + node2._ports = [EthernetPort("E0", 0, 0, 4)] + async_run(link.add_node(node2, 0, 4)) + + link.update = AsyncioMagicMock() + assert link._created + async_run(link.update_filters({ + "packet_loss": ["10"], + "delay": ["50", "10"], + "frequency_drop": ["0"] + })) + assert link.filters == { + "packet_loss": [10], + "delay": [50, 10] + } + assert link.update.called + + +def test_available_filters(async_run, project, compute): + node1 = Node(project, compute, "node1", node_type="qemu") + node1._ports = [EthernetPort("E0", 0, 0, 4)] + + link = Link(project) + link.create = AsyncioMagicMock() + assert link.available_filters() == [] + + # Qemu is not supported should return 0 filters + async_run(link.add_node(node1, 0, 4)) + assert link.available_filters() == [] + + node2 = Node(project, compute, "node2", node_type="vpcs") + node2._ports = [EthernetPort("E0", 0, 0, 4)] + async_run(link.add_node(node2, 0, 4)) + assert len(link.available_filters()) > 0 diff --git a/tests/controller/test_project.py b/tests/controller/test_project.py index cbafaa51..23d32aa9 100644 --- a/tests/controller/test_project.py +++ b/tests/controller/test_project.py @@ -612,4 +612,4 @@ def test_add_iou_node_and_check_if_gets_application_id(project, async_run): node = async_run(project.add_node( compute, "test", None, node_type="iou", application_id=333, properties={"startup_config": "test.cfg"})) assert mocked_get_app_id.called - assert node.properties['application_id'] == 333 \ No newline at end of file + assert node.properties['application_id'] == 333 diff --git a/tests/controller/test_udp_link.py b/tests/controller/test_udp_link.py index 023630bb..3ab6581f 100644 --- a/tests/controller/test_udp_link.py +++ b/tests/controller/test_udp_link.py @@ -19,7 +19,7 @@ import pytest import asyncio import aiohttp from unittest.mock import MagicMock -from tests.utils import asyncio_patch, AsyncioMagicMock +from tests.utils import AsyncioMagicMock from gns3server.controller.project import Project from gns3server.controller.udp_link import UDPLink @@ -52,6 +52,7 @@ def test_create(async_run, project): link = UDPLink(project) async_run(link.add_node(node1, 0, 4)) + async_run(link.update_filters({"latency": [10]})) @asyncio.coroutine def compute1_callback(path, data={}, **kwargs): @@ -83,13 +84,15 @@ def test_create(async_run, project): "lport": 1024, "rhost": "192.168.1.2", "rport": 2048, - "type": "nio_udp" + "type": "nio_udp", + "filters": {"latency": [10]} }, timeout=120) compute2.post.assert_any_call("/projects/{}/vpcs/nodes/{}/adapters/3/ports/1/nio".format(project.id, node2.id), data={ "lport": 2048, "rhost": "192.168.1.1", "rport": 1024, - "type": "nio_udp" + "type": "nio_udp", + "filters": {} }, timeout=120) @@ -147,13 +150,15 @@ def test_create_one_side_failure(async_run, project): "lport": 1024, "rhost": "192.168.1.2", "rport": 2048, - "type": "nio_udp" + "type": "nio_udp", + "filters": {} }, timeout=120) compute2.post.assert_any_call("/projects/{}/vpcs/nodes/{}/adapters/3/ports/1/nio".format(project.id, node2.id), data={ "lport": 2048, "rhost": "192.168.1.1", "rport": 1024, - "type": "nio_udp" + "type": "nio_udp", + "filters": {} }, timeout=120) # The link creation has failed we rollback the nio compute1.delete.assert_any_call("/projects/{}/vpcs/nodes/{}/adapters/0/ports/4/nio".format(project.id, node1.id), timeout=120) @@ -302,3 +307,77 @@ def test_node_updated(project, async_run): node_vpcs._status = "stopped" async_run(link.node_updated(node_vpcs)) assert link.stop_capture.called + + +def test_update(async_run, project): + compute1 = MagicMock() + compute2 = MagicMock() + + node1 = Node(project, compute1, "node1", node_type="vpcs") + node1._ports = [EthernetPort("E0", 0, 0, 4)] + node2 = Node(project, compute2, "node2", node_type="vpcs") + node2._ports = [EthernetPort("E0", 0, 3, 1)] + + @asyncio.coroutine + def subnet_callback(compute2): + """ + Fake subnet callback + """ + return ("192.168.1.1", "192.168.1.2") + + compute1.get_ip_on_same_subnet.side_effect = subnet_callback + + link = UDPLink(project) + async_run(link.add_node(node1, 0, 4)) + async_run(link.update_filters({"latency": [10]})) + + @asyncio.coroutine + def compute1_callback(path, data={}, **kwargs): + """ + Fake server + """ + if "/ports/udp" in path: + response = MagicMock() + response.json = {"udp_port": 1024} + return response + + @asyncio.coroutine + def compute2_callback(path, data={}, **kwargs): + """ + Fake server + """ + if "/ports/udp" in path: + response = MagicMock() + response.json = {"udp_port": 2048} + return response + + compute1.post.side_effect = compute1_callback + compute1.host = "example.com" + compute2.post.side_effect = compute2_callback + compute2.host = "example.org" + async_run(link.add_node(node2, 3, 1)) + + compute1.post.assert_any_call("/projects/{}/vpcs/nodes/{}/adapters/0/ports/4/nio".format(project.id, node1.id), data={ + "lport": 1024, + "rhost": "192.168.1.2", + "rport": 2048, + "type": "nio_udp", + "filters": {"latency": [10]} + }, timeout=120) + compute2.post.assert_any_call("/projects/{}/vpcs/nodes/{}/adapters/3/ports/1/nio".format(project.id, node2.id), data={ + "lport": 2048, + "rhost": "192.168.1.1", + "rport": 1024, + "type": "nio_udp", + "filters": {} + }, timeout=120) + + assert link.created + async_run(link.update_filters({"drop": [5]})) + compute1.put.assert_any_call("/projects/{}/vpcs/nodes/{}/adapters/0/ports/4/nio".format(project.id, node1.id), data={ + "lport": 1024, + "rhost": "192.168.1.2", + "rport": 2048, + "type": "nio_udp", + "filters": {"drop": [5]} + }, timeout=120) diff --git a/tests/handlers/api/compute/test_vpcs.py b/tests/handlers/api/compute/test_vpcs.py index 85456e5e..265a11fd 100644 --- a/tests/handlers/api/compute/test_vpcs.py +++ b/tests/handlers/api/compute/test_vpcs.py @@ -74,6 +74,20 @@ def test_vpcs_nio_create_udp(http_compute, vm): assert response.json["type"] == "nio_udp" +def test_vpcs_nio_update_udp(http_compute, vm): + response = http_compute.put("/projects/{project_id}/vpcs/nodes/{node_id}/adapters/0/ports/0/nio".format(project_id=vm["project_id"], node_id=vm["node_id"]), + { + "type": "nio_udp", + "lport": 4242, + "rport": 4343, + "rhost": "127.0.0.1", + "filters": {}}, + example=True) + assert response.status == 201 + assert response.route == "/projects/{project_id}/vpcs/nodes/{node_id}/adapters/{adapter_number:\d+}/ports/{port_number:\d+}/nio" + assert response.json["type"] == "nio_udp" + + @pytest.mark.skipif(sys.platform.startswith("win"), reason="Not supported on Windows") def test_vpcs_nio_create_tap(http_compute, vm, ethernet_device): with patch("gns3server.compute.base_manager.BaseManager.has_privileged_access", return_value=True): diff --git a/tests/handlers/api/controller/test_link.py b/tests/handlers/api/controller/test_link.py index a7e3194e..e1ff8fa6 100644 --- a/tests/handlers/api/controller/test_link.py +++ b/tests/handlers/api/controller/test_link.py @@ -33,7 +33,7 @@ from gns3server.handlers.api.controller.project_handler import ProjectHandler from gns3server.controller import Controller from gns3server.controller.ports.ethernet_port import EthernetPort from gns3server.controller.node import Node -from gns3server.controller.link import Link +from gns3server.controller.link import Link, FILTERS @pytest.fixture @@ -59,6 +59,11 @@ def test_create_link(http_controller, tmpdir, project, compute, async_run): node2 = async_run(project.add_node(compute, "node2", None, node_type="qemu")) node2._ports = [EthernetPort("E0", 0, 2, 4)] + filters = { + "latency": [10], + "frequency_drop": [50] + } + with asyncio_patch("gns3server.controller.udp_link.UDPLink.create") as mock: response = http_controller.post("/projects/{}/links".format(project.id), { "nodes": [ @@ -77,7 +82,8 @@ def test_create_link(http_controller, tmpdir, project, compute, async_run): "adapter_number": 2, "port_number": 4 } - ] + ], + "filters": filters }, example=True) assert mock.called assert response.status == 201 @@ -85,6 +91,7 @@ def test_create_link(http_controller, tmpdir, project, compute, async_run): assert len(response.json["nodes"]) == 2 assert response.json["nodes"][0]["label"]["x"] == 42 assert len(project.links) == 1 + assert list(project.links.values())[0].filters == filters def test_create_link_failure(http_controller, tmpdir, project, compute, async_run): @@ -135,6 +142,11 @@ def test_update_link(http_controller, tmpdir, project, compute, async_run): node2 = async_run(project.add_node(compute, "node2", None, node_type="qemu")) node2._ports = [EthernetPort("E0", 0, 2, 4)] + filters = { + "latency": 10, + "frequency_drop": 50 + } + with asyncio_patch("gns3server.controller.udp_link.UDPLink.create") as mock: response = http_controller.post("/projects/{}/links".format(project.id), { "nodes": [ @@ -174,10 +186,12 @@ def test_update_link(http_controller, tmpdir, project, compute, async_run): "adapter_number": 2, "port_number": 4 } - ] + ], + "filters": filters }) assert response.status == 201 assert response.json["nodes"][0]["label"]["x"] == 64 + assert list(project.links.values())[0].filters == filters def test_list_link(http_controller, tmpdir, project, compute, async_run): @@ -190,24 +204,31 @@ def test_list_link(http_controller, tmpdir, project, compute, async_run): node2 = async_run(project.add_node(compute, "node2", None, node_type="qemu")) node2._ports = [EthernetPort("E0", 0, 2, 4)] + filters = { + "latency": 10, + "frequency_drop": 50 + } + nodes = [ + { + "node_id": node1.id, + "adapter_number": 0, + "port_number": 3 + }, + { + "node_id": node2.id, + "adapter_number": 2, + "port_number": 4 + } + ] with asyncio_patch("gns3server.controller.udp_link.UDPLink.create") as mock: response = http_controller.post("/projects/{}/links".format(project.id), { - "nodes": [ - { - "node_id": node1.id, - "adapter_number": 0, - "port_number": 3 - }, - { - "node_id": node2.id, - "adapter_number": 2, - "port_number": 4 - } - ] + "nodes": nodes, + "filters": filters }) response = http_controller.get("/projects/{}/links".format(project.id), example=True) assert response.status == 200 assert len(response.json) == 1 + assert response.json[0]["filters"] == filters def test_start_capture(http_controller, tmpdir, project, compute, async_run): @@ -258,3 +279,14 @@ def test_delete_link(http_controller, tmpdir, project, compute, async_run): response = http_controller.delete("/projects/{}/links/{}".format(project.id, link.id), example=True) assert mock.called assert response.status == 204 + + +def test_list_filters(http_controller, tmpdir, project, async_run): + + link = Link(project) + project._links = {link.id: link} + with patch("gns3server.controller.link.Link.available_filters", return_value=FILTERS) as mock: + response = http_controller.get("/projects/{}/links/{}/available_filters".format(project.id, link.id), example=True) + assert mock.called + assert response.status == 200 + assert response.json == FILTERS From 4097bab565e77ddbc37c4a3cbb3541ce19e00a8a Mon Sep 17 00:00:00 2001 From: Julien Duponchelle Date: Wed, 5 Jul 2017 16:36:39 +0200 Subject: [PATCH 2/3] Apply feedback from @grossmj --- gns3server/compute/base_node.py | 4 ++-- gns3server/compute/vpcs/vpcs_vm.py | 2 +- gns3server/controller/link.py | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/gns3server/compute/base_node.py b/gns3server/compute/base_node.py index 5f1ba36a..47640cf6 100644 --- a/gns3server/compute/base_node.py +++ b/gns3server/compute/base_node.py @@ -602,11 +602,11 @@ class BaseNode: """ yield from self._ubridge_send('bridge reset_packet_filters ' + bridge_name) i = 0 - for (type, values) in filters.items(): + for (filter_type, values) in filters.items(): cmd = "bridge add_packet_filter {bridge_name} {filter_name} {filter_type} {filter_value}".format( bridge_name=bridge_name, filter_name="filter" + str(i), - filter_type=type, + filter_type=filter_type, filter_value=" ".join([str(v) for v in values])) yield from self._ubridge_send(cmd) i += 1 diff --git a/gns3server/compute/vpcs/vpcs_vm.py b/gns3server/compute/vpcs/vpcs_vm.py index 91f7c677..8de816aa 100644 --- a/gns3server/compute/vpcs/vpcs_vm.py +++ b/gns3server/compute/vpcs/vpcs_vm.py @@ -381,7 +381,7 @@ class VPCSVM(BaseNode): if self.ubridge: yield from self._add_ubridge_udp_connection("VPCS-{}".format(self._id), self._local_udp_tunnel[1], nio) elif self.is_running(): - raise VPCSError("Sorry, adding a link to a started VPCS instance is not supported without using uBridge.") + raise VPCSError("Sorry, updating a link to a started VPCS instance is not supported without using uBridge.") self._ethernet_adapter.add_nio(port_number, nio) log.info('VPCS "{name}" [{id}]: {nio} added to port {port_number}'.format(name=self._name, diff --git a/gns3server/controller/link.py b/gns3server/controller/link.py index b36b30cb..585a7e7e 100644 --- a/gns3server/controller/link.py +++ b/gns3server/controller/link.py @@ -75,7 +75,7 @@ FILTERS = [ { "type": "corrupt", "name": "Corrupt", - "description": "The percentage represents the chance for a packet to be corrupt", + "description": "The percentage represents the chance for a packet to be corrupted", "parameters": [ { "name": "Frequency", From 8f260e0a8308c0bd88ca16fb5ee4e6525959a2e6 Mon Sep 17 00:00:00 2001 From: grossmj Date: Thu, 6 Jul 2017 16:53:05 +0700 Subject: [PATCH 3/3] Update field names for filters. --- gns3server/controller/link.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/gns3server/controller/link.py b/gns3server/controller/link.py index 585a7e7e..9e202bfb 100644 --- a/gns3server/controller/link.py +++ b/gns3server/controller/link.py @@ -46,7 +46,7 @@ FILTERS = [ "description": "The percentage represents the chance for a packet to be lost", "parameters": [ { - "name": "Frequency", + "name": "Chance", "minimum": 0, "maximum": 100, "unit": "%" @@ -59,13 +59,13 @@ FILTERS = [ "description": "Delay packets in milliseconds. You can add jitter in milliseconds (+/-) of the delay", "parameters": [ { - "name": "Delay", + "name": "Latency", "minimum": 0, "maximum": 32767, "unit": "ms" }, { - "name": "Jitter", + "name": "Jitter (-/+)", "minimum": 0, "maximum": 32767, "unit": "ms" @@ -78,7 +78,7 @@ FILTERS = [ "description": "The percentage represents the chance for a packet to be corrupted", "parameters": [ { - "name": "Frequency", + "name": "Chance", "minimum": 0, "maximum": 100, "unit": "%"