From d64f3c22c132c5de0d759d1e76ff7ced054bfcaa Mon Sep 17 00:00:00 2001 From: Kali Kaneko Date: Fri, 11 Aug 2017 00:59:56 +0200 Subject: [feature] automatic vpn gateway selection, based on timezone This is a first approach to automatic gateways selection. More things are missing: - allow manual selection, by location or country code. - take the hemisphere into account. - expose the selected gw to the api/cli but overall seems this is a good approach to make 0.10 release usable in terms of vpn. - Resolves: #8804 --- docs/changelog.rst | 3 +- src/leap/bitmask/chrome/chromeapp.py | 1 + src/leap/bitmask/core/service.py | 2 +- src/leap/bitmask/vpn/gateways.py | 143 +++++++++++++++++++++++++++++++++++ src/leap/bitmask/vpn/launcher.py | 51 ------------- src/leap/bitmask/vpn/service.py | 21 +++-- tests/unit/vpn/test_gateways.py | 128 +++++++++++++++++++++++++++++++ 7 files changed, 288 insertions(+), 61 deletions(-) create mode 100644 src/leap/bitmask/vpn/gateways.py create mode 100644 tests/unit/vpn/test_gateways.py diff --git a/docs/changelog.rst b/docs/changelog.rst index c9b9445c..c89f51ed 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -15,6 +15,7 @@ Features - `#8821 `_: Add a 'fetch' flag to key export - `#8049 `_: Restart the VPN automatically - `#8852 `_: Stop the vpn (and all services) when application is shut down +- `#8804 `_: Automatic selection of gateways, based on user timezone - Initial cli port of the legacy vpn code - Add VPN API to bitmask.js - Add vpn get_cert command @@ -24,7 +25,7 @@ Features - Port Pixelated UA integration from legacy bitmask - Add Pixelated Button to the UI - Add ability to ssh into the bitmask daemon for debug -- New ``bitmask_chromium`` gui: launches Bitmask UI as a standalone chromium app if chromium is installed in your system. +- New ``bitmask_chromium`` gui: launches Bitmask UI as a standalone chromium app if chromium is installed in your system Bugfixes ~~~~~~~~ diff --git a/src/leap/bitmask/chrome/chromeapp.py b/src/leap/bitmask/chrome/chromeapp.py index b71fbdc7..feb765d6 100644 --- a/src/leap/bitmask/chrome/chromeapp.py +++ b/src/leap/bitmask/chrome/chromeapp.py @@ -51,6 +51,7 @@ def get_url(): url += '#' + token return url + def delete_old_authtoken(): try: os.remove(AUTHTOKEN_PATH) diff --git a/src/leap/bitmask/core/service.py b/src/leap/bitmask/core/service.py index ec3536f2..5b4f5f79 100644 --- a/src/leap/bitmask/core/service.py +++ b/src/leap/bitmask/core/service.py @@ -39,7 +39,7 @@ from leap.common.events import server as event_server try: from leap.bitmask.vpn.service import VPNService HAS_VPN = True -except ImportError: +except ImportError as exc: HAS_VPN = False diff --git a/src/leap/bitmask/vpn/gateways.py b/src/leap/bitmask/vpn/gateways.py new file mode 100644 index 00000000..950e37c3 --- /dev/null +++ b/src/leap/bitmask/vpn/gateways.py @@ -0,0 +1,143 @@ +# -*- coding: utf-8 -*- +# gateways.py +# Copyright (C) 2013-2017 LEAP Encryption Access Project +# +# 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 . + +""" +Gateway Selection +""" + +import logging +import os +import re +import time + +from leap.common.config import get_path_prefix + + +class GatewaySelector(object): + + # http://www.timeanddate.com/time/map/ + equivalent_timezones = {13: -11, 14: -10} + + def __init__(self, gateways=None, locations=None, tz_offset=None): + ''' + Constructor for GatewaySelector. + + :param tz_offset: use this offset as a local distance to GMT. + :type tz_offset: int + ''' + if gateways is None: + gateways = [] + if locations is None: + locations = {} + self.gateways = gateways + self.locations = locations + self._local_offset = tz_offset + if tz_offset is None: + tz_offset = self._get_local_offset() + if tz_offset in self.equivalent_timezones: + tz_offset = self.equivalent_timezones[tz_offset] + self._local_offset = tz_offset + + def select_gateways(self): + """ + Returns the IPs top 4 preferred gateways, in order. + """ + gateways = [gateway[1] for gateway in self.get_sorted_gateways()][:4] + return gateways + + def get_sorted_gateways(self): + """ + Returns a tuple with location-label, IP and Country Code with all the + available gateways, sorted by order of preference. + """ + gateways_timezones = [] + locations = self.locations + + for idx, gateway in enumerate(self.gateways): + distance = 99 # if hasn't location -> should go last + location = locations.get(gateway.get('location')) + + label = gateway.get('location', 'Unknown') + country = 'XX' + if location is not None: + country = location.get('country_code', 'XX') + label = location.get('name', label) + timezone = location.get('timezone') + if timezone is not None: + offset = int(timezone) + if offset in self.equivalent_timezones: + offset = self.equivalent_timezones[offset] + distance = self._get_timezone_distance(offset) + ip = self.gateways[idx].get('ip_address') + gateways_timezones.append((ip, distance, label, country)) + + gateways_timezones = sorted(gateways_timezones, key=lambda gw: gw[1]) + + result = [] + for ip, distance, label, country in gateways_timezones: + result.append((label, ip, country)) + return result + + def get_gateways_country_code(self): + country_codes = {} + locations = self.locations + if not locations: + return + gateways = self.gateways + + for idx, gateway in enumerate(gateways): + gateway_location = gateway.get('location') + + ip = self._eipconfig.get_gateway_ip(idx) + if gateway_location is not None: + ccode = locations[gateway['location']]['country_code'] + country_codes[ip] = ccode + return country_codes + + def _get_timezone_distance(self, offset): + ''' + Return the distance between the local timezone and + the one with offset 'offset'. + + :param offset: the distance of a timezone to GMT. + :type offset: int + :returns: distance between local offset and param offset. + :rtype: int + ''' + timezones = range(-11, 13) + tz1 = offset + tz2 = self._local_offset + distance = abs(timezones.index(tz1) - timezones.index(tz2)) + if distance > 12: + if tz1 < 0: + distance = timezones.index(tz1) + timezones[::-1].index(tz2) + else: + distance = timezones[::-1].index(tz1) + timezones.index(tz2) + + return distance + + def _get_local_offset(self): + ''' + Return the distance between GMT and the local timezone. + + :rtype: int + ''' + local_offset = time.timezone + if time.daylight: + local_offset = time.altzone + + return -local_offset / 3600 diff --git a/src/leap/bitmask/vpn/launcher.py b/src/leap/bitmask/vpn/launcher.py index 466f6d8a..5f4881c7 100644 --- a/src/leap/bitmask/vpn/launcher.py +++ b/src/leap/bitmask/vpn/launcher.py @@ -109,57 +109,6 @@ class VPNLauncher(object): PREFERRED_PORTS = ("443", "80", "53", "1194") - # FIXME -- dead code? - @classmethod - @abstractmethod - def get_gateways(kls, vpnconfig, providerconfig): - """ - Return a list with the selected gateways for a given provider, looking - at the VPN config file. - Each item of the list is a tuple containing (gateway, port). - - :param vpnconfig: vpn configuration object - :type vpnconfig: VPNConfig - - :param providerconfig: provider specific configuration - :type providerconfig: ProviderConfig - - :rtype: list - """ - gateways = [] - - settings = Settings() - domain = providerconfig.get_domain() - gateway_conf = settings.get_selected_gateway(domain) - gateway_selector = VPNGatewaySelector(vpnconfig) - - if gateway_conf == GATEWAY_AUTOMATIC: - gws = gateway_selector.get_gateways() - else: - gws = [gateway_conf] - - if not gws: - log.error('No gateway was found!') - raise VPNLauncherException('No gateway was found!') - - for idx, gw in enumerate(gws): - ports = vpnconfig.get_gateway_ports(idx) - - the_port = "1194" # default port - - # pick the port preferring this order: - for port in kls.PREFERRED_PORTS: - if port in ports: - the_port = port - break - else: - continue - - gateways.append((gw, the_port)) - - log.debug('Using gateways (ip, port): {0!r}'.format(gateways)) - return gateways - @classmethod @abstractmethod def get_vpn_command(kls, vpnconfig, providerconfig, diff --git a/src/leap/bitmask/vpn/service.py b/src/leap/bitmask/vpn/service.py index 93080feb..8bcee2e8 100644 --- a/src/leap/bitmask/vpn/service.py +++ b/src/leap/bitmask/vpn/service.py @@ -28,6 +28,7 @@ from twisted.logger import Logger from leap.bitmask.hooks import HookableService from leap.bitmask.util import merge_status +from leap.bitmask.vpn.gateways import GatewaySelector from leap.bitmask.vpn.fw.firewall import FirewallManager from leap.bitmask.vpn.tunnel import TunnelManager from leap.bitmask.vpn._checks import is_service_ready, get_vpn_cert_path @@ -198,14 +199,15 @@ class VPNService(HookableService): bonafide = self.parent.getServiceNamed("bonafide") config = yield bonafide.do_provider_read(provider, "eip") - # TODO - add gateway selection ability. - # First thing, we should port the TimezonSelector - remotes = [(gw["ip_address"], gw["capabilities"]["ports"][0]) - for gw in config.gateways] + sorted_gateways = GatewaySelector( + config.gateways, config.locations).select_gateways() + + # TODO - add manual gateway selection ability. + extra_flags = config.openvpn_configuration - prefix = os.path.join(self._basepath, "leap", "providers", provider, - "keys") + prefix = os.path.join( + self._basepath, "leap", "providers", provider, "keys") cert_path = key_path = os.path.join(prefix, "client", "openvpn.pem") ca_path = os.path.join(prefix, "ca", "cacert.pem") @@ -217,13 +219,16 @@ class VPNService(HookableService): 'Cannot find provider certificate. ' 'Please configure provider.') + # TODO add remote ports, according to preferred sequence + remotes = tuple([(ip, '443') for ip in sorted_gateways]) self._tunnel = TunnelManager( provider, remotes, cert_path, key_path, ca_path, extra_flags) self._firewall = FirewallManager(remotes) def _cert_expires(self, provider): - path = os.path.join(self._basepath, "leap", "providers", provider, - "keys", "client", "openvpn.pem") + path = os.path.join( + self._basepath, "leap", "providers", provider, + "keys", "client", "openvpn.pem") with open(path, 'r') as f: cert = f.read() _, to = get_cert_time_boundaries(cert) diff --git a/tests/unit/vpn/test_gateways.py b/tests/unit/vpn/test_gateways.py new file mode 100644 index 00000000..cc7fbca3 --- /dev/null +++ b/tests/unit/vpn/test_gateways.py @@ -0,0 +1,128 @@ +# -*- coding: utf-8 -*- +# test_gateways.py +# Copyright (C) 2017 LEAP Encryption Access Project +# +# 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 . + +""" +tests for leap.bitmask.vpn.gateways +""" +import time + +from twisted.trial import unittest + +from leap.bitmask.vpn.gateways import GatewaySelector + + +sample_gateways = [ + {u'host': u'gateway1.com', + u'ip_address': u'1.2.3.4', + u'location': u'location1'}, + {u'host': u'gateway2.com', + u'ip_address': u'2.3.4.5', + u'location': u'location2'}, + {u'host': u'gateway3.com', + u'ip_address': u'3.4.5.6', + u'location': u'location3'}, + {u'host': u'gateway4.com', + u'ip_address': u'4.5.6.7', + u'location': u'location4'} +] + +sample_gateways_no_location = [ + {u'host': u'gateway1.com', + u'ip_address': u'1.2.3.4'}, + {u'host': u'gateway2.com', + u'ip_address': u'2.3.4.5'}, + {u'host': u'gateway3.com', + u'ip_address': u'3.4.5.6'} +] + +sample_locations = { + u'location1': {u'timezone': u'2'}, + u'location2': {u'timezone': u'-7'}, + u'location3': {u'timezone': u'-4'}, + u'location4': {u'timezone': u'+13'} +} + +# 0 is not used, only for indexing from 1 in tests +ips = (0, u'1.2.3.4', u'2.3.4.5', u'3.4.5.6', u'4.5.6.7') + + +class GatewaySelectorTestCase(unittest.TestCase): + + def test_get_no_gateways(self): + selector = GatewaySelector() + gateways = selector.select_gateways() + assert gateways == [] + + def test_get_gateway_with_no_locations(self): + selector = GatewaySelector( + gateways=sample_gateways_no_location) + gateways = selector.select_gateways() + gateways_default_order = [ + sample_gateways[0]['ip_address'], + sample_gateways[1]['ip_address'], + sample_gateways[2]['ip_address'] + ] + assert gateways == gateways_default_order + + def test_correct_order_gmt(self): + selector = GatewaySelector( + sample_gateways, sample_locations, + tz_offset=0) + gateways = selector.select_gateways() + assert gateways == [ips[1], ips[3], ips[2], ips[4]] + + def test_correct_order_gmt_minus_3(self): + selector = GatewaySelector( + sample_gateways, sample_locations, + tz_offset=-3) + gateways = selector.select_gateways() + assert gateways == [ips[3], ips[2], ips[1], ips[4]] + + def test_correct_order_gmt_minus_7(self): + selector = GatewaySelector( + sample_gateways, sample_locations, + tz_offset=-7) + gateways = selector.select_gateways() + assert gateways == [ips[2], ips[3], ips[4], ips[1]] + + def test_correct_order_gmt_plus_5(self): + selector = GatewaySelector( + sample_gateways, sample_locations, + tz_offset=5) + gateways = selector.select_gateways() + assert gateways == [ips[1], ips[4], ips[3], ips[2]] + + def test_correct_order_gmt_plus_12(self): + selector = GatewaySelector( + sample_gateways, sample_locations, + tz_offset=12) + gateways = selector.select_gateways() + assert gateways == [ips[4], ips[2], ips[3], ips[1]] + + def test_correct_order_gmt_minus_11(self): + selector = GatewaySelector( + sample_gateways, sample_locations, + -11) + gateways = selector.select_gateways() + assert gateways == [ips[4], ips[2], ips[3], ips[1]] + + def test_correct_order_gmt_plus_14(self): + selector = GatewaySelector( + sample_gateways, sample_locations, + 14) + gateways = selector.select_gateways() + assert gateways == [ips[4], ips[2], ips[3], ips[1]] -- cgit v1.2.3