summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorKali Kaneko <kali@leap.se>2017-08-11 00:59:56 +0200
committerKali Kaneko <kali@leap.se>2017-08-11 14:21:57 -0400
commitd64f3c22c132c5de0d759d1e76ff7ced054bfcaa (patch)
treecf14b625d1206ccdf44769f3ee2e14985730dc0d
parent763f88658a4e6d12557c7931f5435ebd35548ca7 (diff)
[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
-rw-r--r--docs/changelog.rst3
-rw-r--r--src/leap/bitmask/chrome/chromeapp.py1
-rw-r--r--src/leap/bitmask/core/service.py2
-rw-r--r--src/leap/bitmask/vpn/gateways.py143
-rw-r--r--src/leap/bitmask/vpn/launcher.py51
-rw-r--r--src/leap/bitmask/vpn/service.py21
-rw-r--r--tests/unit/vpn/test_gateways.py128
7 files changed, 288 insertions, 61 deletions
diff --git a/docs/changelog.rst b/docs/changelog.rst
index c9b9445..c89f51e 100644
--- a/docs/changelog.rst
+++ b/docs/changelog.rst
@@ -15,6 +15,7 @@ Features
- `#8821 <https://0xacab.org/leap/bitmask-dev/issues/8821>`_: Add a 'fetch' flag to key export
- `#8049 <https://0xacab.org/leap/bitmask-dev/issues/8049>`_: Restart the VPN automatically
- `#8852 <https://0xacab.org/leap/bitmask-dev/issues/8852>`_: Stop the vpn (and all services) when application is shut down
+- `#8804 <https://0xacab.org/leap/bitmask-dev/issues/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 b71fbdc..feb765d 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 ec3536f..5b4f5f7 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 0000000..950e37c
--- /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 <http://www.gnu.org/licenses/>.
+
+"""
+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 466f6d8..5f4881c 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 93080fe..8bcee2e 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 0000000..cc7fbca
--- /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 <http://www.gnu.org/licenses/>.
+
+"""
+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]]