From 134fed8fc5d5f130275a7a1f19613427548ceb57 Mon Sep 17 00:00:00 2001 From: Julien Duponchelle Date: Thu, 25 Aug 2016 19:14:29 +0200 Subject: [PATCH] Smart choice of host for UDP link --- gns3server/controller/compute.py | 59 ++++++++++++++++++++ gns3server/controller/udp_link.py | 12 +++-- tests/controller/test_compute.py | 90 +++++++++++++++++++++++++++++++ tests/controller/test_udp_link.py | 13 ++++- 4 files changed, 169 insertions(+), 5 deletions(-) diff --git a/gns3server/controller/compute.py b/gns3server/controller/compute.py index 77ee6f679..e59c81020 100644 --- a/gns3server/controller/compute.py +++ b/gns3server/controller/compute.py @@ -15,6 +15,7 @@ # 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 socket @@ -96,6 +97,9 @@ class Compute: # Websocket for notifications self._ws = None + # Cache of interfaces on remote host + self._interfaces_cache = None + def _session(self): if self._http_session is None or self._http_session.closed is True: self._http_session = aiohttp.ClientSession() @@ -122,6 +126,16 @@ class Compute: self._password = None self._auth = aiohttp.BasicAuth(self._user, "") + @asyncio.coroutine + def interfaces(self): + """ + Get the list of network on compute + """ + if not self._interfaces_cache: + response = yield from self.get("/network/interfaces") + self._interfaces_cache = response.json + return self._interfaces_cache + @asyncio.coroutine def update(self, **kwargs): for kw in kwargs: @@ -192,6 +206,13 @@ class Compute: """ return self._host + @property + def host_ip(self): + """ + Return the IP associated to the host + """ + return socket.gethostbyname(self._host) + @host.setter def host(self, host): self._host = host @@ -491,3 +512,41 @@ class Compute: path = "/projects/{}/files".format(project.id) res = yield from self.http_query("GET", path, timeout=120) return res.json + + @asyncio.coroutine + def get_ip_on_same_subnet(self, other_compute): + """ + Try to found 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) + + this_compute_interfaces = yield from self.interfaces() + other_compute_interfaces = yield from 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: + if len(this_interface["ip_address"]) == 0: + 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: + 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("No common subnet for compute {} and {}".format(self.name, other_compute.name)) diff --git a/gns3server/controller/udp_link.py b/gns3server/controller/udp_link.py index 96f2a6ce1..830d4e396 100644 --- a/gns3server/controller/udp_link.py +++ b/gns3server/controller/udp_link.py @@ -41,7 +41,13 @@ class UDPLink(Link): adapter_number2 = self._nodes[1]["adapter_number"] port_number2 = self._nodes[1]["port_number"] - # Reserve a UDP port on both side + # Get an IP allowing communication between both host + try: + (node1_host, node2_host) = yield from node1.compute.get_ip_on_same_subnet(node2.compute) + except ValueError as e: + raise aiohttp.web.HTTPConflict(text=str(e)) + + # Reserve a UDP port on both side response = yield from node1.compute.post("/projects/{}/ports/udp".format(self._project.id)) self._node1_port = response.json["udp_port"] response = yield from node2.compute.post("/projects/{}/ports/udp".format(self._project.id)) @@ -50,7 +56,7 @@ class UDPLink(Link): # Create the tunnel on both side data = { "lport": self._node1_port, - "rhost": node2.compute.host, + "rhost": node2_host, "rport": self._node2_port, "type": "nio_udp" } @@ -58,7 +64,7 @@ class UDPLink(Link): data = { "lport": self._node2_port, - "rhost": node1.compute.host, + "rhost": node1_host, "rport": self._node1_port, "type": "nio_udp" } diff --git a/tests/controller/test_compute.py b/tests/controller/test_compute.py index 9e6e5ebe8..7ae607580 100644 --- a/tests/controller/test_compute.py +++ b/tests/controller/test_compute.py @@ -40,6 +40,11 @@ def test_init(compute): assert compute.id == "my_compute_id" +def test_host_ip(controller): + compute = Compute("my_compute_id", protocol="https", host="localhost", port=84, controller=controller) + assert compute.host_ip == "127.0.0.1" + + def test_name(): c = Compute("my_compute_id", protocol="https", host="example.com", port=84, controller=MagicMock(), name=None) assert c.name == "https://example.com:84" @@ -323,3 +328,88 @@ def test_list_files(project, async_run, compute): with asyncio_patch("aiohttp.ClientSession.request", return_value=response) as mock: assert async_run(compute.list_files(project)) == res mock.assert_any_call("GET", "https://example.com:84/v2/compute/projects/{}/files".format(project.id), auth=None, chunked=False, data=None, headers={'content-type': 'application/json'}) + + +def test_interfaces(project, async_run, compute): + res = [ + { + "id": "vmnet99", + "ip_address": "172.16.97.1", + "mac_address": "00:50:56:c0:00:63", + "name": "vmnet99", + "netmask": "255.255.255.0", + "type": "ethernet" + } + ] + response = AsyncioMagicMock() + response.read = AsyncioMagicMock(return_value=json.dumps(res).encode()) + response.status = 200 + with asyncio_patch("aiohttp.ClientSession.request", return_value=response) as mock: + assert async_run(compute.interfaces()) == res + mock.assert_any_call("GET", "https://example.com:84/v2/compute/network/interfaces", auth=None, chunked=False, data=None, headers={'content-type': 'application/json'}) + + +def test_get_ip_on_same_subnet(controller, async_run): + compute1 = Compute("compute1", host="192.168.1.1", controller=controller) + compute1._interfaces_cache = [ + { + "ip_address": "127.0.0.1", + "netmask": "255.255.255.255" + }, + { + "ip_address": "192.168.2.1", + "netmask": "255.255.255.0" + }, + { + "ip_address": "192.168.1.1", + "netmask": "255.255.255.0" + }, + ] + + # Case 1 both host are on the same network + compute2 = Compute("compute2", host="192.168.1.2", controller=controller) + compute2._interfaces_cache = [ + { + "ip_address": "127.0.0.1", + "netmask": "255.255.255.255" + }, + { + "ip_address": "192.168.2.2", + "netmask": "255.255.255.0" + }, + { + "ip_address": "192.168.1.2", + "netmask": "255.255.255.0" + } + ] + assert async_run(compute1.get_ip_on_same_subnet(compute2)) == ("192.168.1.1", "192.168.1.2") + + # Case 2 compute2 host is on a different network but a common interface is available + compute2 = Compute("compute2", host="192.168.4.2", controller=controller) + compute2._interfaces_cache = [ + { + "ip_address": "127.0.0.1", + "netmask": "255.255.255.255" + }, + { + "ip_address": "192.168.4.2", + "netmask": "255.255.255.0" + }, + { + "ip_address": "192.168.1.2", + "netmask": "255.255.255.0" + } + ] + assert async_run(compute1.get_ip_on_same_subnet(compute2)) == ("192.168.1.1", "192.168.1.2") + + #No common interface + # Case 2 compute2 host is on a different network but a common interface is available + compute2 = Compute("compute2", host="127.0.0.1", controller=controller) + compute2._interfaces_cache = [ + { + "ip_address": "127.0.0.1", + "netmask": "255.255.255.255" + } + ] + with pytest.raises(ValueError): + async_run(compute1.get_ip_on_same_subnet(compute2)) diff --git a/tests/controller/test_udp_link.py b/tests/controller/test_udp_link.py index 1df2c7f6b..33a21c4f4 100644 --- a/tests/controller/test_udp_link.py +++ b/tests/controller/test_udp_link.py @@ -38,6 +38,15 @@ def test_create(async_run, project): node1 = Node(project, compute1, "node1", node_type="vpcs") node2 = Node(project, compute2, "node2", node_type="vpcs") + @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.add_node(node2, 3, 1)) @@ -70,13 +79,13 @@ def test_create(async_run, project): compute1.post.assert_any_call("/projects/{}/vpcs/nodes/{}/adapters/0/ports/4/nio".format(project.id, node1.id), data={ "lport": 1024, - "rhost": compute2.host, + "rhost": "192.168.1.2", "rport": 2048, "type": "nio_udp" }) compute2.post.assert_any_call("/projects/{}/vpcs/nodes/{}/adapters/3/ports/1/nio".format(project.id, node2.id), data={ "lport": 2048, - "rhost": compute1.host, + "rhost": "192.168.1.1", "rport": 1024, "type": "nio_udp" })