summaryrefslogtreecommitdiff
path: root/src/leap/bitmask
diff options
context:
space:
mode:
authorTomás Touceda <chiiph@leap.se>2013-08-12 16:25:50 -0300
committerTomás Touceda <chiiph@leap.se>2013-08-12 16:25:50 -0300
commit75a1b6e96b789a8d3d4b9b22bbf62e30ffe62751 (patch)
treecc39f23e95bdbff7495cc866e2f51c1c4f54bc32 /src/leap/bitmask
parent733fd79e1da439604bd45587417fe466a6af9d92 (diff)
parent3c7981e61d3b48f9a000d08056ff79e993c71ce1 (diff)
Merge remote-tracking branch 'kali/feature/create_bitmask_namespace' into develop
Diffstat (limited to 'src/leap/bitmask')
-rw-r--r--src/leap/bitmask/__init__.py0
-rw-r--r--src/leap/bitmask/_version.py201
-rw-r--r--src/leap/bitmask/app.py224
-rw-r--r--src/leap/bitmask/config/__init__.py0
-rw-r--r--src/leap/bitmask/config/leapsettings.py253
-rw-r--r--src/leap/bitmask/config/provider_spec.py105
-rw-r--r--src/leap/bitmask/config/providerconfig.py218
-rw-r--r--src/leap/bitmask/config/tests/test_providerconfig.py279
-rw-r--r--src/leap/bitmask/crypto/__init__.py0
-rw-r--r--src/leap/bitmask/crypto/srpauth.py606
-rw-r--r--src/leap/bitmask/crypto/srpregister.py168
-rw-r--r--src/leap/bitmask/crypto/tests/__init__.py16
-rw-r--r--src/leap/bitmask/crypto/tests/eip-service.json43
-rwxr-xr-xsrc/leap/bitmask/crypto/tests/fake_provider.py376
-rw-r--r--src/leap/bitmask/crypto/tests/openvpn.pem33
-rw-r--r--src/leap/bitmask/crypto/tests/test_provider.json15
-rw-r--r--src/leap/bitmask/crypto/tests/test_srpauth.py791
-rw-r--r--src/leap/bitmask/crypto/tests/test_srpregister.py201
-rw-r--r--src/leap/bitmask/crypto/tests/wrongcert.pem33
-rw-r--r--src/leap/bitmask/gui/__init__.py21
-rw-r--r--src/leap/bitmask/gui/loggerwindow.py139
-rw-r--r--src/leap/bitmask/gui/login.py245
-rw-r--r--src/leap/bitmask/gui/mainwindow.py1542
-rw-r--r--src/leap/bitmask/gui/statuspanel.py460
-rw-r--r--src/leap/bitmask/gui/twisted_main.py60
-rw-r--r--src/leap/bitmask/gui/ui/loggerwindow.ui155
-rw-r--r--src/leap/bitmask/gui/ui/login.ui132
-rw-r--r--src/leap/bitmask/gui/ui/mainwindow.ui315
-rw-r--r--src/leap/bitmask/gui/ui/statuspanel.ui289
-rw-r--r--src/leap/bitmask/gui/ui/wizard.ui846
-rw-r--r--src/leap/bitmask/gui/wizard.py628
-rw-r--r--src/leap/bitmask/gui/wizardpage.py40
-rw-r--r--src/leap/bitmask/platform_init/__init__.py28
-rw-r--r--src/leap/bitmask/platform_init/initializers.py409
-rw-r--r--src/leap/bitmask/platform_init/locks.py405
-rw-r--r--src/leap/bitmask/provider/__init__.py0
-rw-r--r--src/leap/bitmask/provider/supportedapis.py38
-rw-r--r--src/leap/bitmask/services/__init__.py33
-rw-r--r--src/leap/bitmask/services/abstractbootstrapper.py164
-rw-r--r--src/leap/bitmask/services/eip/__init__.py0
-rw-r--r--src/leap/bitmask/services/eip/eipbootstrapper.py183
-rw-r--r--src/leap/bitmask/services/eip/eipconfig.py263
-rw-r--r--src/leap/bitmask/services/eip/eipspec.py85
-rw-r--r--src/leap/bitmask/services/eip/providerbootstrapper.py340
-rw-r--r--src/leap/bitmask/services/eip/tests/__init__.py0
-rw-r--r--src/leap/bitmask/services/eip/tests/test_eipbootstrapper.py347
-rw-r--r--src/leap/bitmask/services/eip/tests/test_eipconfig.py324
-rw-r--r--src/leap/bitmask/services/eip/tests/test_providerbootstrapper.py532
-rw-r--r--src/leap/bitmask/services/eip/tests/test_vpngatewayselector.py132
-rw-r--r--src/leap/bitmask/services/eip/tests/wrongcert.pem33
-rw-r--r--src/leap/bitmask/services/eip/udstelnet.py60
-rw-r--r--src/leap/bitmask/services/eip/vpnlaunchers.py927
-rw-r--r--src/leap/bitmask/services/eip/vpnprocess.py791
-rw-r--r--src/leap/bitmask/services/mail/__init__.py0
-rw-r--r--src/leap/bitmask/services/mail/imap.py42
-rw-r--r--src/leap/bitmask/services/mail/smtpbootstrapper.py139
-rw-r--r--src/leap/bitmask/services/mail/smtpconfig.py49
-rw-r--r--src/leap/bitmask/services/mail/smtpspec.py70
-rw-r--r--src/leap/bitmask/services/soledad/__init__.py0
-rw-r--r--src/leap/bitmask/services/soledad/soledadbootstrapper.py265
-rw-r--r--src/leap/bitmask/services/soledad/soledadconfig.py49
-rw-r--r--src/leap/bitmask/services/soledad/soledadspec.py76
-rw-r--r--src/leap/bitmask/services/tests/__init__.py0
-rw-r--r--src/leap/bitmask/services/tests/test_abstractbootstrapper.py197
-rw-r--r--src/leap/bitmask/services/tx.py46
-rw-r--r--src/leap/bitmask/util/__init__.py105
-rw-r--r--src/leap/bitmask/util/constants.py19
-rw-r--r--src/leap/bitmask/util/keyring_helpers.py37
-rw-r--r--src/leap/bitmask/util/leap_argparse.py72
-rw-r--r--src/leap/bitmask/util/leap_log_handler.py134
-rw-r--r--src/leap/bitmask/util/privilege_policies.py169
-rw-r--r--src/leap/bitmask/util/pyside_tests_helper.py136
-rw-r--r--src/leap/bitmask/util/reqs.txt14
-rw-r--r--src/leap/bitmask/util/request_helpers.py56
-rw-r--r--src/leap/bitmask/util/requirement_checker.py101
-rw-r--r--src/leap/bitmask/util/streamtologger.py59
-rw-r--r--src/leap/bitmask/util/tests/__init__.py0
-rw-r--r--src/leap/bitmask/util/tests/test_is_release_version.py57
-rw-r--r--src/leap/bitmask/util/tests/test_leap_log_handler.py120
-rw-r--r--src/leap/bitmask/util/tests/test_streamtologger.py122
80 files changed, 15662 insertions, 0 deletions
diff --git a/src/leap/bitmask/__init__.py b/src/leap/bitmask/__init__.py
new file mode 100644
index 00000000..e69de29b
--- /dev/null
+++ b/src/leap/bitmask/__init__.py
diff --git a/src/leap/bitmask/_version.py b/src/leap/bitmask/_version.py
new file mode 100644
index 00000000..412b0c9e
--- /dev/null
+++ b/src/leap/bitmask/_version.py
@@ -0,0 +1,201 @@
+
+IN_LONG_VERSION_PY = True
+# This file helps to compute a version number in source trees obtained from
+# git-archive tarball (such as those provided by githubs download-from-tag
+# feature). Distribution tarballs (build by setup.py sdist) and build
+# directories (produced by setup.py build) will contain a much shorter file
+# that just contains the computed version number.
+
+# This file is released into the public domain. Generated by
+# versioneer-0.7+ (https://github.com/warner/python-versioneer)
+
+# these strings will be replaced by git during git-archive
+git_refnames = "$Format:%d$"
+git_full = "$Format:%H$"
+
+
+import subprocess
+import sys
+import re
+import os.path
+
+
+def run_command(args, cwd=None, verbose=False):
+ try:
+ # remember shell=False, so use git.cmd on windows, not just git
+ p = subprocess.Popen(args, stdout=subprocess.PIPE, cwd=cwd)
+ except EnvironmentError:
+ e = sys.exc_info()[1]
+ if verbose:
+ print("unable to run %s" % args[0])
+ print(e)
+ return None
+ stdout = p.communicate()[0].strip()
+ if sys.version >= '3':
+ stdout = stdout.decode()
+ if p.returncode != 0:
+ if verbose:
+ print("unable to run %s (error)" % args[0])
+ return None
+ return stdout
+
+
+def get_expanded_variables(versionfile_source):
+ # the code embedded in _version.py can just fetch the value of these
+ # variables. When used from setup.py, we don't want to import
+ # _version.py, so we do it with a regexp instead. This function is not
+ # used from _version.py.
+ variables = {}
+ try:
+ for line in open(versionfile_source, "r").readlines():
+ if line.strip().startswith("git_refnames ="):
+ mo = re.search(r'=\s*"(.*)"', line)
+ if mo:
+ variables["refnames"] = mo.group(1)
+ if line.strip().startswith("git_full ="):
+ mo = re.search(r'=\s*"(.*)"', line)
+ if mo:
+ variables["full"] = mo.group(1)
+ except EnvironmentError:
+ pass
+ return variables
+
+
+def versions_from_expanded_variables(variables, tag_prefix, verbose=False):
+ refnames = variables["refnames"].strip()
+ if refnames.startswith("$Format"):
+ if verbose:
+ print("variables are unexpanded, not using")
+ return {} # unexpanded, so not in an unpacked git-archive tarball
+ refs = set([r.strip() for r in refnames.strip("()").split(",")])
+ for ref in list(refs):
+ if not re.search(r'\d', ref):
+ if verbose:
+ print("discarding '%s', no digits" % ref)
+ refs.discard(ref)
+ # Assume all version tags have a digit. git's %d expansion
+ # behaves like git log --decorate=short and strips out the
+ # refs/heads/ and refs/tags/ prefixes that would let us
+ # distinguish between branches and tags. By ignoring refnames
+ # without digits, we filter out many common branch names like
+ # "release" and "stabilization", as well as "HEAD" and "master".
+ if verbose:
+ print("remaining refs: %s" % ",".join(sorted(refs)))
+ for ref in sorted(refs):
+ # sorting will prefer e.g. "2.0" over "2.0rc1"
+ if ref.startswith(tag_prefix):
+ r = ref[len(tag_prefix):]
+ if verbose:
+ print("picking %s" % r)
+ return {"version": r,
+ "full": variables["full"].strip()}
+ # no suitable tags, so we use the full revision id
+ if verbose:
+ print("no suitable tags, using full revision id")
+ return {"version": variables["full"].strip(),
+ "full": variables["full"].strip()}
+
+
+def versions_from_vcs(tag_prefix, versionfile_source, verbose=False):
+ # this runs 'git' from the root of the source tree. That either means
+ # someone ran a setup.py command (and this code is in versioneer.py, so
+ # IN_LONG_VERSION_PY=False, thus the containing directory is the root of
+ # the source tree), or someone ran a project-specific entry point (and
+ # this code is in _version.py, so IN_LONG_VERSION_PY=True, thus the
+ # containing directory is somewhere deeper in the source tree). This only
+ # gets called if the git-archive 'subst' variables were *not* expanded,
+ # and _version.py hasn't already been rewritten with a short version
+ # string, meaning we're inside a checked out source tree.
+
+ try:
+ here = os.path.abspath(__file__)
+ except NameError:
+ # some py2exe/bbfreeze/non-CPython implementations don't do __file__
+ return {} # not always correct
+
+ # versionfile_source is the relative path from the top of the source tree
+ # (where the .git directory might live) to this file. Invert this to find
+ # the root from __file__.
+ root = here
+ if IN_LONG_VERSION_PY:
+ for i in range(len(versionfile_source.split("/"))):
+ root = os.path.dirname(root)
+ else:
+ root = os.path.dirname(here)
+ if not os.path.exists(os.path.join(root, ".git")):
+ if verbose:
+ print("no .git in %s" % root)
+ return {}
+
+ GIT = "git"
+ if sys.platform == "win32":
+ GIT = "git.cmd"
+ stdout = run_command([GIT, "describe", "--tags", "--dirty", "--always"],
+ cwd=root)
+ if stdout is None:
+ return {}
+ if not stdout.startswith(tag_prefix):
+ if verbose:
+ print("tag '%s' doesn't start with prefix '%s'" % (
+ stdout, tag_prefix))
+ return {}
+ tag = stdout[len(tag_prefix):]
+ stdout = run_command([GIT, "rev-parse", "HEAD"], cwd=root)
+ if stdout is None:
+ return {}
+ full = stdout.strip()
+ if tag.endswith("-dirty"):
+ full += "-dirty"
+ return {"version": tag, "full": full}
+
+
+def versions_from_parentdir(parentdir_prefix, versionfile_source,
+ verbose=False):
+ if IN_LONG_VERSION_PY:
+ # We're running from _version.py. If it's from a source tree
+ # (execute-in-place), we can work upwards to find the root of the
+ # tree, and then check the parent directory for a version string. If
+ # it's in an installed application, there's no hope.
+ try:
+ here = os.path.abspath(__file__)
+ except NameError:
+ # py2exe/bbfreeze/non-CPython don't have __file__
+ return {} # without __file__, we have no hope
+ # versionfile_source is the relative path from the top of the source
+ # tree to _version.py. Invert this to find the root from __file__.
+ root = here
+ for i in range(len(versionfile_source.split("/"))):
+ root = os.path.dirname(root)
+ else:
+ # we're running from versioneer.py, which means we're running from
+ # the setup.py in a source tree. sys.argv[0] is setup.py in the root.
+ here = os.path.abspath(sys.argv[0])
+ root = os.path.dirname(here)
+
+ # Source tarballs conventionally unpack into a directory that includes
+ # both the project name and a version string.
+ dirname = os.path.basename(root)
+ if not dirname.startswith(parentdir_prefix):
+ if verbose:
+ print("guessing rootdir is '%s', but '%s' "
+ "doesn't start with prefix '%s'" %
+ (root, dirname, parentdir_prefix))
+ return None
+ return {"version": dirname[len(parentdir_prefix):], "full": ""}
+
+tag_prefix = ""
+parentdir_prefix = "bitmask-"
+versionfile_source = "src/leap/bitmask/_version.py"
+
+
+def get_versions(default={"version": "unknown", "full": ""}, verbose=False):
+ variables = {"refnames": git_refnames, "full": git_full}
+ ver = versions_from_expanded_variables(variables, tag_prefix, verbose)
+ if not ver:
+ ver = versions_from_vcs(tag_prefix, versionfile_source, verbose)
+ if not ver:
+ ver = versions_from_parentdir(parentdir_prefix, versionfile_source,
+ verbose)
+ if not ver:
+ ver = default
+ return ver
diff --git a/src/leap/bitmask/app.py b/src/leap/bitmask/app.py
new file mode 100644
index 00000000..3c418258
--- /dev/null
+++ b/src/leap/bitmask/app.py
@@ -0,0 +1,224 @@
+# -*- coding: utf-8 -*-
+# app.py
+# Copyright (C) 2013 LEAP
+#
+# 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/>.
+
+import logging
+import signal
+import sys
+import os
+
+from functools import partial
+
+from PySide import QtCore, QtGui
+
+from leap.bitmask.gui import locale_rc
+from leap.bitmask.gui import twisted_main
+from leap.bitmask.gui.mainwindow import MainWindow
+from leap.bitmask.platform_init import IS_MAC
+from leap.bitmask.platform_init.locks import we_are_the_one_and_only
+#from leap.bitmask.services.tx import leap_services
+from leap.bitmask.util import __version__ as VERSION
+from leap.bitmask.util import leap_argparse
+from leap.bitmask.util.leap_log_handler import LeapLogHandler
+from leap.bitmask.util.streamtologger import StreamToLogger
+from leap.bitmask.util.requirement_checker import check_requirements
+from leap.common.events import server as event_server
+
+
+import codecs
+codecs.register(lambda name: codecs.lookup('utf-8')
+ if name == 'cp65001' else None)
+
+# pylint: avoid unused import
+assert(locale_rc)
+
+
+def sigint_handler(*args, **kwargs):
+ """
+ Signal handler for SIGINT
+ """
+ logger = kwargs.get('logger', None)
+ if logger:
+ logger.debug("SIGINT catched. shutting down...")
+ mainwindow = args[0]
+ mainwindow.quit()
+
+
+def install_qtreactor(logger):
+ import qt4reactor
+ qt4reactor.install()
+ logger.debug("Qt4 reactor installed")
+
+
+def add_logger_handlers(debug=False, logfile=None):
+ """
+ Create the logger and attach the handlers.
+
+ :param debug: the level of the messages that we should log
+ :type debug: bool
+ :param logfile: the file name of where we should to save the logs
+ :type logfile: str
+ :return: the new logger with the attached handlers.
+ :rtype: logging.Logger
+ """
+ # TODO: get severity from command line args
+ if debug:
+ level = logging.DEBUG
+ else:
+ level = logging.WARNING
+
+ # Create logger and formatter
+ logger = logging.getLogger(name='leap')
+ logger.setLevel(level)
+ log_format = '%(asctime)s - %(name)s - %(levelname)s - %(message)s'
+ formatter = logging.Formatter(log_format)
+
+ # Console handler
+ console = logging.StreamHandler()
+ console.setLevel(level)
+ console.setFormatter(formatter)
+ logger.addHandler(console)
+ logger.debug('Console handler plugged!')
+
+ # LEAP custom handler
+ leap_handler = LeapLogHandler()
+ leap_handler.setLevel(level)
+ logger.addHandler(leap_handler)
+ logger.debug('Leap handler plugged!')
+
+ # File handler
+ if logfile is not None:
+ logger.debug('Setting logfile to %s ', logfile)
+ fileh = logging.FileHandler(logfile)
+ fileh.setLevel(logging.DEBUG)
+ fileh.setFormatter(formatter)
+ logger.addHandler(fileh)
+ logger.debug('File handler plugged!')
+
+ return logger
+
+
+def replace_stdout_stderr_with_logging(logger):
+ """
+ Replace:
+ - the standard output
+ - the standard error
+ - the twisted log output
+ with a custom one that writes to the logger.
+ """
+ sys.stdout = StreamToLogger(logger, logging.DEBUG)
+ sys.stderr = StreamToLogger(logger, logging.ERROR)
+
+ # Replace twisted's logger to use our custom output.
+ from twisted.python import log
+ log.startLogging(sys.stdout)
+
+
+def main():
+ """
+ Starts the main event loop and launches the main window.
+ """
+ try:
+ event_server.ensure_server(event_server.SERVER_PORT)
+ except Exception as e:
+ # We don't even have logger configured in here
+ print "Could not ensure server: %r" % (e,)
+
+ _, opts = leap_argparse.init_leapc_args()
+ standalone = opts.standalone
+ bypass_checks = getattr(opts, 'danger', False)
+ debug = opts.debug
+ logfile = opts.log_file
+ openvpn_verb = opts.openvpn_verb
+
+ logger = add_logger_handlers(debug, logfile)
+ replace_stdout_stderr_with_logging(logger)
+
+ if not we_are_the_one_and_only():
+ # Bitmask is already running
+ logger.warning("Tried to launch more than one instance "
+ "of Bitmask. Raising the existing "
+ "one instead.")
+ sys.exit(1)
+
+ check_requirements()
+
+ logger.info('~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~')
+ logger.info('Bitmask version %s', VERSION)
+ logger.info('~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~')
+
+ logger.info('Starting app')
+
+ # We force the style if on KDE so that it doesn't load all the kde
+ # libs, which causes a compatibility issue in some systems.
+ # For more info, see issue #3194
+ if os.environ.get("KDE_SESSION_UID") is not None:
+ sys.argv.append("-style")
+ sys.argv.append("Cleanlooks")
+
+ app = QtGui.QApplication(sys.argv)
+
+ # install the qt4reactor.
+ install_qtreactor(logger)
+
+ # To test:
+ # $ LANG=es ./app.py
+ locale = QtCore.QLocale.system().name()
+ qtTranslator = QtCore.QTranslator()
+ if qtTranslator.load("qt_%s" % locale, ":/translations"):
+ app.installTranslator(qtTranslator)
+ appTranslator = QtCore.QTranslator()
+ if appTranslator.load("%s.qm" % locale[:2], ":/translations"):
+ app.installTranslator(appTranslator)
+
+ # Needed for initializing qsettings it will write
+ # .config/leap/leap.conf top level app settings in a platform
+ # independent way
+ app.setOrganizationName("leap")
+ app.setApplicationName("leap")
+ app.setOrganizationDomain("leap.se")
+
+ # XXX ---------------------------------------------------------
+ # In quarantine, looks like we don't need it anymore.
+ # This dummy timer ensures that control is given to the outside
+ # loop, so we can hook our sigint handler.
+ #timer = QtCore.QTimer()
+ #timer.start(500)
+ #timer.timeout.connect(lambda: None)
+ # XXX ---------------------------------------------------------
+
+ window = MainWindow(
+ lambda: twisted_main.quit(app),
+ standalone=standalone,
+ openvpn_verb=openvpn_verb,
+ bypass_checks=bypass_checks)
+
+ sigint_window = partial(sigint_handler, window, logger=logger)
+ signal.signal(signal.SIGINT, sigint_window)
+
+ if IS_MAC:
+ window.raise_()
+
+ # This was a good idea, but for this to work as intended we
+ # should centralize the start of all services in there.
+ #tx_app = leap_services()
+ #assert(tx_app)
+
+ # Run main loop
+ twisted_main.start(app)
+
+if __name__ == "__main__":
+ main()
diff --git a/src/leap/bitmask/config/__init__.py b/src/leap/bitmask/config/__init__.py
new file mode 100644
index 00000000..e69de29b
--- /dev/null
+++ b/src/leap/bitmask/config/__init__.py
diff --git a/src/leap/bitmask/config/leapsettings.py b/src/leap/bitmask/config/leapsettings.py
new file mode 100644
index 00000000..35010280
--- /dev/null
+++ b/src/leap/bitmask/config/leapsettings.py
@@ -0,0 +1,253 @@
+# -*- coding: utf-8 -*-
+# leapsettings.py
+# Copyright (C) 2013 LEAP
+#
+# 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/>.
+
+"""
+QSettings abstraction
+"""
+import os
+import logging
+
+from PySide import QtCore
+
+from leap.common.check import leap_assert, leap_assert_type
+from leap.common.config.prefixers import get_platform_prefixer
+
+logger = logging.getLogger(__name__)
+
+
+def to_bool(val):
+ """
+ Returns the boolean value corresponding to val. Will return False
+ in case val is not a string or something that behaves like one.
+
+ :param val: value to cast
+ :type val: either bool already or str
+
+ :rtype: bool
+ """
+ if isinstance(val, bool):
+ return val
+
+ bool_val = False
+ try:
+ bool_val = val.lower() == "true"
+ except:
+ pass
+
+ return bool_val
+
+
+class LeapSettings(object):
+ """
+ Leap client QSettings wrapper
+ """
+
+ CONFIG_NAME = "leap.conf"
+
+ # keys
+ GEOMETRY_KEY = "Geometry"
+ WINDOWSTATE_KEY = "WindowState"
+ USER_KEY = "User"
+ PROPERPROVIDER_KEY = "ProperProvider"
+ REMEMBER_KEY = "RememberUserAndPass"
+ DEFAULTPROVIDER_KEY = "DefaultProvider"
+ ALERTMISSING_KEY = "AlertMissingScripts"
+
+ def __init__(self, standalone=False):
+ """
+ Constructor
+
+ :param standalone: parameter used to define the location of
+ the config
+ :type standalone: bool
+ """
+
+ settings_path = os.path.join(get_platform_prefixer()
+ .get_path_prefix(standalone=standalone),
+ "leap",
+ self.CONFIG_NAME)
+ self._settings = QtCore.QSettings(settings_path,
+ QtCore.QSettings.IniFormat)
+
+ def get_geometry(self):
+ """
+ Returns the saved geometry or None if it wasn't saved
+
+ :rtype: bytearray or None
+ """
+ return self._settings.value(self.GEOMETRY_KEY, None)
+
+ def set_geometry(self, geometry):
+ """
+ Saves the geometry to the settings
+
+ :param geometry: bytearray representing the geometry
+ :type geometry: bytearray
+ """
+ leap_assert(geometry, "We need a geometry")
+ self._settings.setValue(self.GEOMETRY_KEY, geometry)
+
+ def get_windowstate(self):
+ """
+ Returns the window state or None if it wasn't saved
+
+ :rtype: bytearray or None
+ """
+ return self._settings.value(self.WINDOWSTATE_KEY, None)
+
+ def set_windowstate(self, windowstate):
+ """
+ Saves the window state to the settings
+
+ :param windowstate: bytearray representing the window state
+ :type windowstate: bytearray
+ """
+ leap_assert(windowstate, "We need a window state")
+ self._settings.setValue(self.WINDOWSTATE_KEY, windowstate)
+
+ def get_enabled_services(self, provider):
+ """
+ Returns a list of enabled services for the given provider
+
+ :param provider: provider domain
+ :type provider: str
+
+ :rtype: list of str
+ """
+
+ leap_assert(len(provider) > 0, "We need a nonempty provider")
+ enabled_services = self._settings.value("%s/Services" % (provider,),
+ [])
+ if isinstance(enabled_services, (str, unicode)):
+ enabled_services = enabled_services.split(",")
+
+ return enabled_services
+
+ def set_enabled_services(self, provider, services):
+ """
+ Saves the list of enabled services for the given provider
+
+ :param provider: provider domain
+ :type provider: str
+
+ :param services: list of services to save
+ :type services: list of str
+ """
+
+ leap_assert(len(provider) > 0, "We need a nonempty provider")
+ leap_assert_type(services, list)
+
+ self._settings.setValue("%s/Services" % (provider,),
+ services)
+
+ def get_user(self):
+ """
+ Returns the configured user to remember, None if there isn't one
+
+ :rtype: str or None
+ """
+ return self._settings.value(self.USER_KEY, None)
+
+ def set_user(self, user):
+ """
+ Saves the user to remember
+
+ :param user: user name to remember
+ :type user: str
+ """
+ leap_assert(len(user) > 0, "We cannot save an empty user")
+ self._settings.setValue(self.USER_KEY, user)
+
+ def get_remember(self):
+ """
+ Returns the value of the remember selection.
+
+ :rtype: bool
+ """
+ return to_bool(self._settings.value(self.REMEMBER_KEY, False))
+
+ def set_remember(self, remember):
+ """
+ Sets wheter the app should remember username and password
+
+ :param remember: True if the app should remember username and
+ password, False otherwise
+ :rtype: bool
+ """
+ leap_assert_type(remember, bool)
+ self._settings.setValue(self.REMEMBER_KEY, remember)
+
+ # TODO: make this scale with multiple providers, we are assuming
+ # just one for now
+ def get_properprovider(self):
+ """
+ Returns True if there is a properly configured provider.
+
+ .. note:: this assumes only one provider for now.
+
+ :rtype: bool
+ """
+ return to_bool(self._settings.value(self.PROPERPROVIDER_KEY, False))
+
+ def set_properprovider(self, properprovider):
+ """
+ Sets whether the app should automatically login.
+
+ :param properprovider: True if the provider is properly configured,
+ False otherwise.
+ :type properprovider: bool
+ """
+ leap_assert_type(properprovider, bool)
+ self._settings.setValue(self.PROPERPROVIDER_KEY, properprovider)
+
+ def get_defaultprovider(self):
+ """
+ Returns the default provider to be used for autostarting EIP
+
+ :rtype: str or None
+ """
+ return self._settings.value(self.DEFAULTPROVIDER_KEY, None)
+
+ def set_defaultprovider(self, provider):
+ """
+ Sets the default provider to be used for autostarting EIP
+
+ :param provider: provider to use
+ :type provider: str or None
+ """
+ if provider is None:
+ self._settings.remove(self.DEFAULTPROVIDER_KEY)
+ else:
+ self._settings.setValue(self.DEFAULTPROVIDER_KEY, provider)
+
+ def get_alert_missing_scripts(self):
+ """
+ Returns the setting for alerting of missing up/down scripts.
+
+ :rtype: bool
+ """
+ return to_bool(self._settings.value(self.ALERTMISSING_KEY, True))
+
+ def set_alert_missing_scripts(self, value):
+ """
+ Sets the setting for alerting of missing up/down scripts.
+
+ :param value: the value to set
+ :type value: bool
+ """
+ leap_assert_type(value, bool)
+ self._settings.setValue(self.ALERTMISSING_KEY, value)
diff --git a/src/leap/bitmask/config/provider_spec.py b/src/leap/bitmask/config/provider_spec.py
new file mode 100644
index 00000000..cf942c7b
--- /dev/null
+++ b/src/leap/bitmask/config/provider_spec.py
@@ -0,0 +1,105 @@
+# -*- coding: utf-8 -*-
+# provider_spec.py
+# Copyright (C) 2013 LEAP
+#
+# 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/>.
+
+leap_provider_spec = {
+ 'description': 'provider definition',
+ 'type': 'object',
+ 'properties': {
+ 'version': {
+ 'type': unicode,
+ 'default': '0.1.0'
+ },
+ "default_language": {
+ 'type': unicode,
+ 'default': 'en'
+ },
+ 'domain': {
+ 'type': unicode, # XXX define uri type
+ 'default': 'testprovider.example.org'
+ },
+ 'name': {
+ 'type': dict,
+ 'format': 'translatable',
+ 'default': {u'en': u'Test Provider'}
+ },
+ 'description': {
+ #'type': LEAPTranslatable,
+ 'type': dict,
+ 'format': 'translatable',
+ 'default': {u'en': u'Test provider'}
+ },
+ 'enrollment_policy': {
+ 'type': unicode, # oneof ??
+ 'default': 'open'
+ },
+ 'services': {
+ 'type': list, # oneof ??
+ 'default': ['eip']
+ },
+ 'api_version': {
+ 'type': unicode,
+ 'default': '0.1.0' # version regexp
+ },
+ 'api_uri': {
+ 'type': unicode # uri
+ },
+ 'public_key': {
+ 'type': unicode # fingerprint
+ },
+ 'ca_cert_fingerprint': {
+ 'type': unicode,
+ },
+ 'ca_cert_uri': {
+ 'type': unicode,
+ 'format': 'https-uri'
+ },
+ 'languages': {
+ 'type': list,
+ 'default': ['en']
+ },
+ 'service': {
+ 'levels': {
+ 'type': list
+ },
+ 'default_service_level': {
+ 'type': int,
+ 'default': 1
+ },
+ 'allow_free': {
+ 'type': unicode
+ },
+ 'allow_paid': {
+ 'type': unicode
+ },
+ 'allow_anonymous': {
+ 'type': unicode
+ },
+ 'allow_registration': {
+ 'type': unicode
+ },
+ 'bandwidth_limit': {
+ 'type': int
+ },
+ 'allow_limited_bandwidth': {
+ 'type': unicode
+ },
+ 'allow_unlimited_bandwidth': {
+ 'type': unicode
+ }
+ }
+ }
+}
diff --git a/src/leap/bitmask/config/providerconfig.py b/src/leap/bitmask/config/providerconfig.py
new file mode 100644
index 00000000..c65932be
--- /dev/null
+++ b/src/leap/bitmask/config/providerconfig.py
@@ -0,0 +1,218 @@
+# -*- coding: utf-8 -*-
+# providerconfig.py
+# Copyright (C) 2013 LEAP
+#
+# 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/>.
+
+"""
+Provider configuration
+"""
+import logging
+import os
+
+from leap.bitmask.config.provider_spec import leap_provider_spec
+from leap.common.check import leap_check
+from leap.common.config.baseconfig import BaseConfig, LocalizedKey
+
+logger = logging.getLogger(__name__)
+
+
+class MissingCACert(Exception):
+ """
+ Raised when a CA certificate is needed but not found.
+ """
+ pass
+
+
+class ProviderConfig(BaseConfig):
+ """
+ Provider configuration abstraction class
+ """
+ def __init__(self):
+ BaseConfig.__init__(self)
+
+ def _get_schema(self):
+ """
+ Returns the schema corresponding to the version given.
+
+ :rtype: dict or None if the version is not supported.
+ """
+ return leap_provider_spec
+
+ def _get_spec(self):
+ """
+ Returns the spec object for the specific configuration.
+
+ Override the BaseConfig one because we do not support multiple schemas
+ for the provider yet.
+
+ :rtype: dict or None if the version is not supported.
+ """
+ return self._get_schema()
+
+ def get_api_uri(self):
+ return self._safe_get_value("api_uri")
+
+ def get_api_version(self):
+ return self._safe_get_value("api_version")
+
+ def get_ca_cert_fingerprint(self):
+ return self._safe_get_value("ca_cert_fingerprint")
+
+ def get_ca_cert_uri(self):
+ return self._safe_get_value("ca_cert_uri")
+
+ def get_default_language(self):
+ return self._safe_get_value("default_language")
+
+ @LocalizedKey
+ def get_description(self):
+ return self._safe_get_value("description")
+
+ @classmethod
+ def sanitize_path_component(cls, component):
+ """
+ If the provider tries to instrument the component of a path
+ that is controlled by them, this will take care of
+ removing/escaping all the necessary elements.
+
+ :param component: Path component to process
+ :type component: unicode or str
+
+ :returns: The path component properly escaped
+ :rtype: unicode or str
+ """
+ # TODO: Fix for windows, names like "aux" or "con" aren't
+ # allowed.
+ return component.replace(os.path.sep, "")
+
+ def get_domain(self):
+ return ProviderConfig.sanitize_path_component(
+ self._safe_get_value("domain"))
+
+ def get_enrollment_policy(self):
+ """
+ Returns the enrollment policy
+
+ :rtype: string
+ """
+ return self._safe_get_value("enrollment_policy")
+
+ def get_languages(self):
+ return self._safe_get_value("languages")
+
+ @LocalizedKey
+ def get_name(self):
+ return self._safe_get_value("name")
+
+ def get_services(self):
+ """
+ Returns a list with the available services in the current provider.
+
+ :rtype: list
+ """
+ services = self._safe_get_value("services")
+ return services
+
+ def get_services_string(self):
+ """
+ Returns a string with the available services in the current
+ provider, ready to be shown to the user.
+ """
+ services_str = ", ".join(self.get_services())
+ services_str = services_str.replace(
+ "openvpn", "Encrypted Internet")
+ return services_str
+
+ def get_ca_cert_path(self, about_to_download=False):
+ """
+ Returns the path to the certificate for the current provider.
+ It may raise MissingCACert if
+ the certificate does not exists and not about_to_download
+
+ :param about_to_download: defines wether we want the path to
+ download the cert or not. This helps avoid
+ checking if the cert exists because we
+ are about to write it.
+ :type about_to_download: bool
+ """
+
+ cert_path = os.path.join(self.get_path_prefix(),
+ "leap",
+ "providers",
+ self.get_domain(),
+ "keys",
+ "ca",
+ "cacert.pem")
+
+ if not about_to_download:
+ cert_exists = os.path.exists(cert_path)
+ error_msg = "You need to download the certificate first"
+ leap_check(cert_exists, error_msg, MissingCACert)
+ logger.debug("Going to verify SSL against %s" % (cert_path,))
+
+ return cert_path
+
+ def provides_eip(self):
+ """
+ Returns True if this particular provider has the EIP service,
+ False otherwise.
+
+ :rtype: bool
+ """
+ return "openvpn" in self.get_services()
+
+ def provides_mx(self):
+ """
+ Returns True if this particular provider has the MX service,
+ False otherwise.
+
+ :rtype: bool
+ """
+ return "mx" in self.get_services()
+
+
+if __name__ == "__main__":
+ logger = logging.getLogger(name='leap')
+ logger.setLevel(logging.DEBUG)
+ console = logging.StreamHandler()
+ console.setLevel(logging.DEBUG)
+ formatter = logging.Formatter(
+ '%(asctime)s '
+ '- %(name)s - %(levelname)s - %(message)s')
+ console.setFormatter(formatter)
+ logger.addHandler(console)
+
+ provider = ProviderConfig()
+
+ try:
+ provider.get_api_version()
+ except Exception as e:
+ assert isinstance(e, AssertionError), "Expected an assert"
+ print "Safe value getting is working"
+
+ # standalone minitest
+ #if provider.load("provider_bad.json"):
+ if provider.load("leap/providers/bitmask.net/provider.json"):
+ print provider.get_api_version()
+ print provider.get_ca_cert_fingerprint()
+ print provider.get_ca_cert_uri()
+ print provider.get_default_language()
+ print provider.get_description()
+ print provider.get_description(lang="asd")
+ print provider.get_domain()
+ print provider.get_enrollment_policy()
+ print provider.get_languages()
+ print provider.get_name()
+ print provider.get_services()
diff --git a/src/leap/bitmask/config/tests/test_providerconfig.py b/src/leap/bitmask/config/tests/test_providerconfig.py
new file mode 100644
index 00000000..7661a1ce
--- /dev/null
+++ b/src/leap/bitmask/config/tests/test_providerconfig.py
@@ -0,0 +1,279 @@
+# -*- coding: utf-8 -*-
+# test_providerconfig.py
+# Copyright (C) 2013 LEAP
+#
+# 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 providerconfig
+"""
+
+try:
+ import unittest2 as unittest
+except ImportError:
+ import unittest
+
+import os
+import json
+import copy
+
+from leap.bitmask.config.providerconfig import ProviderConfig, MissingCACert
+from leap.bitmask.services import get_supported
+from leap.common.testing.basetest import BaseLeapTest
+
+from mock import Mock
+
+
+sample_config = {
+ "api_uri": "https://api.test.bitmask.net:4430",
+ "api_version": "1",
+ "ca_cert_fingerprint":
+ "SHA256: 0f17c033115f6b76ff67871872303ff65034efe7dd1b910062ca323eb4da5c7e",
+ "ca_cert_uri": "https://test.bitmask.net/ca.crt",
+ "default_language": "en",
+ "description": {
+ "en": "Test description for provider",
+ "es": "Descripcion de prueba para el proveedor"
+ },
+ "domain": "test.bitmask.net",
+ "enrollment_policy": "open",
+ "languages": [
+ "en",
+ "es"
+ ],
+ "name": {
+ "en": "Bitmask testing environment",
+ "es": "Entorno de pruebas de Bitmask"
+ },
+ "service": {
+ "allow_anonymous": True,
+ "allow_free": True,
+ "allow_limited_bandwidth": True,
+ "allow_paid": False,
+ "allow_registration": True,
+ "allow_unlimited_bandwidth": False,
+ "bandwidth_limit": 400000,
+ "default_service_level": 1,
+ "levels": [
+ {
+ "bandwidth": "limited",
+ "id": 1,
+ "name": "anonymous"
+ },
+ {
+ "bandwidth": "limited",
+ "id": 2,
+ "name": "free",
+ "storage": 50
+ }
+ ]
+ },
+ "services": [
+ "openvpn"
+ ]
+}
+
+
+class ProviderConfigTest(BaseLeapTest):
+ """Tests for ProviderConfig"""
+
+ def setUp(self):
+ self._provider_config = ProviderConfig()
+ json_string = json.dumps(sample_config)
+ self._provider_config.load(data=json_string)
+
+ # At certain points we are going to be replacing these method
+ # to avoid creating a file.
+ # We need to save the old implementation and restore it in
+ # tearDown so we are sure everything is as expected for each
+ # test. If we do it inside each specific test, a failure in
+ # the test will leave the implementation with the mock.
+ self._old_ospath_exists = os.path.exists
+
+ def tearDown(self):
+ os.path.exists = self._old_ospath_exists
+
+ def test_configs_ok(self):
+ """
+ Test if the configs loads ok
+ """
+ # TODO: this test should go to the BaseConfig tests
+ pc = self._provider_config
+ self.assertEqual(pc.get_api_uri(), sample_config['api_uri'])
+ self.assertEqual(pc.get_api_version(), sample_config['api_version'])
+ self.assertEqual(pc.get_ca_cert_fingerprint(),
+ sample_config['ca_cert_fingerprint'])
+ self.assertEqual(pc.get_ca_cert_uri(), sample_config['ca_cert_uri'])
+ self.assertEqual(pc.get_default_language(),
+ sample_config['default_language'])
+
+ self.assertEqual(pc.get_domain(), sample_config['domain'])
+ self.assertEqual(pc.get_enrollment_policy(),
+ sample_config['enrollment_policy'])
+ self.assertEqual(pc.get_languages(), sample_config['languages'])
+
+ def test_localizations(self):
+ pc = self._provider_config
+
+ self.assertEqual(pc.get_description(lang='en'),
+ sample_config['description']['en'])
+ self.assertEqual(pc.get_description(lang='es'),
+ sample_config['description']['es'])
+
+ self.assertEqual(pc.get_name(lang='en'), sample_config['name']['en'])
+ self.assertEqual(pc.get_name(lang='es'), sample_config['name']['es'])
+
+ def _localize(self, lang):
+ """
+ Helper to change default language of the provider config.
+ """
+ pc = self._provider_config
+ config = copy.deepcopy(sample_config)
+ config['default_language'] = lang
+ json_string = json.dumps(config)
+ pc.load(data=json_string)
+
+ return config
+
+ def test_default_localization1(self):
+ pc = self._provider_config
+ config = self._localize(sample_config['languages'][0])
+
+ default_language = config['default_language']
+ default_description = config['description'][default_language]
+ default_name = config['name'][default_language]
+
+ self.assertEqual(pc.get_description(lang='xx'), default_description)
+ self.assertEqual(pc.get_description(), default_description)
+
+ self.assertEqual(pc.get_name(lang='xx'), default_name)
+ self.assertEqual(pc.get_name(), default_name)
+
+ def test_default_localization2(self):
+ pc = self._provider_config
+ config = self._localize(sample_config['languages'][1])
+
+ default_language = config['default_language']
+ default_description = config['description'][default_language]
+ default_name = config['name'][default_language]
+
+ self.assertEqual(pc.get_description(lang='xx'), default_description)
+ self.assertEqual(pc.get_description(), default_description)
+
+ self.assertEqual(pc.get_name(lang='xx'), default_name)
+ self.assertEqual(pc.get_name(), default_name)
+
+ def test_get_ca_cert_path_as_expected(self):
+ pc = self._provider_config
+ pc.get_path_prefix = Mock(return_value='test')
+
+ provider_domain = sample_config['domain']
+ expected_path = os.path.join('test', 'leap', 'providers',
+ provider_domain, 'keys', 'ca',
+ 'cacert.pem')
+
+ # mock 'os.path.exists' so we don't get an error for unexisting file
+ os.path.exists = Mock(return_value=True)
+ cert_path = pc.get_ca_cert_path()
+
+ self.assertEqual(cert_path, expected_path)
+
+ def test_get_ca_cert_path_about_to_download(self):
+ pc = self._provider_config
+ pc.get_path_prefix = Mock(return_value='test')
+
+ provider_domain = sample_config['domain']
+ expected_path = os.path.join('test', 'leap', 'providers',
+ provider_domain, 'keys', 'ca',
+ 'cacert.pem')
+
+ cert_path = pc.get_ca_cert_path(about_to_download=True)
+
+ self.assertEqual(cert_path, expected_path)
+
+ def test_get_ca_cert_path_fails(self):
+ pc = self._provider_config
+ pc.get_path_prefix = Mock(return_value='test')
+
+ # mock 'get_domain' so we don't need to load a config
+ provider_domain = 'test.provider.com'
+ pc.get_domain = Mock(return_value=provider_domain)
+
+ with self.assertRaises(MissingCACert):
+ pc.get_ca_cert_path()
+
+ def test_provides_eip(self):
+ pc = self._provider_config
+ config = copy.deepcopy(sample_config)
+
+ # It provides
+ config['services'] = ['openvpn', 'test_service']
+ json_string = json.dumps(config)
+ pc.load(data=json_string)
+ self.assertTrue(pc.provides_eip())
+
+ # It does not provides
+ config['services'] = ['test_service', 'other_service']
+ json_string = json.dumps(config)
+ pc.load(data=json_string)
+ self.assertFalse(pc.provides_eip())
+
+ def test_provides_mx(self):
+ pc = self._provider_config
+ config = copy.deepcopy(sample_config)
+
+ # It provides
+ config['services'] = ['mx', 'other_service']
+ json_string = json.dumps(config)
+ pc.load(data=json_string)
+ self.assertTrue(pc.provides_mx())
+
+ # It does not provides
+ config['services'] = ['test_service', 'other_service']
+ json_string = json.dumps(config)
+ pc.load(data=json_string)
+ self.assertFalse(pc.provides_mx())
+
+ def test_supports_unknown_service(self):
+ pc = self._provider_config
+ config = copy.deepcopy(sample_config)
+
+ config['services'] = ['unknown']
+ json_string = json.dumps(config)
+ pc.load(data=json_string)
+ self.assertFalse('unknown' in get_supported(pc.get_services()))
+
+ def test_provides_unknown_service(self):
+ pc = self._provider_config
+ config = copy.deepcopy(sample_config)
+
+ config['services'] = ['unknown']
+ json_string = json.dumps(config)
+ pc.load(data=json_string)
+ self.assertTrue('unknown' in pc.get_services())
+
+ def test_get_services_string(self):
+ pc = self._provider_config
+ config = copy.deepcopy(sample_config)
+ config['services'] = [
+ 'openvpn', 'asdf', 'openvpn', 'not_supported_service']
+ json_string = json.dumps(config)
+ pc.load(data=json_string)
+
+ self.assertEqual(pc.get_services_string(),
+ "Encrypted Internet, asdf, Encrypted Internet,"
+ " not_supported_service")
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/src/leap/bitmask/crypto/__init__.py b/src/leap/bitmask/crypto/__init__.py
new file mode 100644
index 00000000..e69de29b
--- /dev/null
+++ b/src/leap/bitmask/crypto/__init__.py
diff --git a/src/leap/bitmask/crypto/srpauth.py b/src/leap/bitmask/crypto/srpauth.py
new file mode 100644
index 00000000..2d34bb74
--- /dev/null
+++ b/src/leap/bitmask/crypto/srpauth.py
@@ -0,0 +1,606 @@
+# -*- coding: utf-8 -*-
+# srpauth.py
+# Copyright (C) 2013 LEAP
+#
+# 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/>.
+
+import binascii
+import logging
+
+import requests
+import srp
+import json
+
+#this error is raised from requests
+from simplejson.decoder import JSONDecodeError
+from functools import partial
+
+from PySide import QtCore
+from twisted.internet import threads
+
+from leap.bitmask.util import request_helpers as reqhelper
+from leap.bitmask.util.constants import REQUEST_TIMEOUT
+from leap.common.check import leap_assert
+from leap.common.events import signal as events_signal
+from leap.common.events import events_pb2 as proto
+
+logger = logging.getLogger(__name__)
+
+
+class SRPAuthenticationError(Exception):
+ """
+ Exception raised for authentication errors
+ """
+ pass
+
+
+class SRPAuthConnectionError(SRPAuthenticationError):
+ """
+ Exception raised when there's a connection error
+ """
+ pass
+
+
+class SRPAuthUnknownUser(SRPAuthenticationError):
+ """
+ Exception raised when trying to authenticate an unknown user
+ """
+ pass
+
+
+class SRPAuthBadStatusCode(SRPAuthenticationError):
+ """
+ Exception raised when we received an unknown bad status code
+ """
+ pass
+
+
+class SRPAuthNoSalt(SRPAuthenticationError):
+ """
+ Exception raised when we don't receive the salt param at a
+ specific point in the auth process
+ """
+ pass
+
+
+class SRPAuthNoB(SRPAuthenticationError):
+ """
+ Exception raised when we don't receive the B param at a specific
+ point in the auth process
+ """
+ pass
+
+
+class SRPAuthBadDataFromServer(SRPAuthenticationError):
+ """
+ Generic exception when we receive bad data from the server.
+ """
+ pass
+
+
+class SRPAuthJSONDecodeError(SRPAuthenticationError):
+ """
+ Exception raised when there's a problem decoding the JSON content
+ parsed as received from th e server.
+ """
+ pass
+
+
+class SRPAuthBadPassword(SRPAuthenticationError):
+ """
+ Exception raised when the user provided a bad password to auth.
+ """
+ pass
+
+
+class SRPAuthVerificationFailed(SRPAuthenticationError):
+ """
+ Exception raised when we can't verify the SRP data received from
+ the server.
+ """
+ pass
+
+
+class SRPAuthNoSessionId(SRPAuthenticationError):
+ """
+ Exception raised when we don't receive a session id from the
+ server.
+ """
+ pass
+
+
+class SRPAuth(QtCore.QObject):
+ """
+ SRPAuth singleton
+ """
+
+ class __impl(QtCore.QObject):
+ """
+ Implementation of the SRPAuth interface
+ """
+
+ LOGIN_KEY = "login"
+ A_KEY = "A"
+ CLIENT_AUTH_KEY = "client_auth"
+ SESSION_ID_KEY = "_session_id"
+
+ def __init__(self, provider_config):
+ """
+ Constructor for SRPAuth implementation
+
+ :param server: Server to which we will authenticate
+ :type server: str
+ """
+ QtCore.QObject.__init__(self)
+
+ leap_assert(provider_config,
+ "We need a provider config to authenticate")
+
+ self._provider_config = provider_config
+
+ # **************************************************** #
+ # Dependency injection helpers, override this for more
+ # granular testing
+ self._fetcher = requests
+ self._srp = srp
+ self._hashfun = self._srp.SHA256
+ self._ng = self._srp.NG_1024
+ # **************************************************** #
+
+ self._session = self._fetcher.session()
+ self._session_id = None
+ self._session_id_lock = QtCore.QMutex()
+ self._uid = None
+ self._uid_lock = QtCore.QMutex()
+ self._token = None
+ self._token_lock = QtCore.QMutex()
+
+ self._srp_user = None
+ self._srp_a = None
+
+ def _safe_unhexlify(self, val):
+ """
+ Rounds the val to a multiple of 2 and returns the
+ unhexlified value
+
+ :param val: hexlified value
+ :type val: str
+
+ :rtype: binary hex data
+ :return: unhexlified val
+ """
+ return binascii.unhexlify(val) \
+ if (len(val) % 2 == 0) else binascii.unhexlify('0' + val)
+
+ def _authentication_preprocessing(self, username, password):
+ """
+ Generates the SRP.User to get the A SRP parameter
+
+ :param username: username to login
+ :type username: str
+ :param password: password for the username
+ :type password: str
+ """
+ logger.debug("Authentication preprocessing...")
+ self._srp_user = self._srp.User(username,
+ password,
+ self._hashfun,
+ self._ng)
+ _, A = self._srp_user.start_authentication()
+
+ self._srp_a = A
+
+ def _start_authentication(self, _, username):
+ """
+ Sends the first request for authentication to retrieve the
+ salt and B parameter
+
+ Might raise all SRPAuthenticationError based:
+ SRPAuthenticationError
+ SRPAuthConnectionError
+ SRPAuthUnknownUser
+ SRPAuthBadStatusCode
+ SRPAuthNoSalt
+ SRPAuthNoB
+
+ :param _: IGNORED, output from the previous callback (None)
+ :type _: IGNORED
+ :param username: username to login
+ :type username: str
+
+ :return: salt and B parameters
+ :rtype: tuple
+ """
+ logger.debug("Starting authentication process...")
+ try:
+ auth_data = {
+ self.LOGIN_KEY: username,
+ self.A_KEY: binascii.hexlify(self._srp_a)
+ }
+ sessions_url = "%s/%s/%s/" % \
+ (self._provider_config.get_api_uri(),
+ self._provider_config.get_api_version(),
+ "sessions")
+ init_session = self._session.post(sessions_url,
+ data=auth_data,
+ verify=self._provider_config.
+ get_ca_cert_path(),
+ timeout=REQUEST_TIMEOUT)
+ # Clean up A value, we don't need it anymore
+ self._srp_a = None
+ except requests.exceptions.ConnectionError as e:
+ logger.error("No connection made (salt): %r" %
+ (e,))
+ raise SRPAuthConnectionError("Could not establish a "
+ "connection")
+ except Exception as e:
+ logger.error("Unknown error: %r" % (e,))
+ raise SRPAuthenticationError("Unknown error: %r" %
+ (e,))
+
+ content, mtime = reqhelper.get_content(init_session)
+
+ if init_session.status_code not in (200,):
+ logger.error("No valid response (salt): "
+ "Status code = %r. Content: %r" %
+ (init_session.status_code, content))
+ if init_session.status_code == 422:
+ raise SRPAuthUnknownUser(self.tr("Unknown user"))
+
+ raise SRPAuthBadStatusCode(self.tr("There was a problem with"
+ " authentication"))
+
+ json_content = json.loads(content)
+ salt = json_content.get("salt", None)
+ B = json_content.get("B", None)
+
+ if salt is None:
+ logger.error("No salt parameter sent")
+ raise SRPAuthNoSalt(self.tr("The server did not send "
+ "the salt parameter"))
+ if B is None:
+ logger.error("No B parameter sent")
+ raise SRPAuthNoB(self.tr("The server did not send "
+ "the B parameter"))
+
+ return salt, B
+
+ def _process_challenge(self, salt_B, username):
+ """
+ Given the salt and B processes the auth challenge and
+ generates the M2 parameter
+
+ Might raise SRPAuthenticationError based:
+ SRPAuthenticationError
+ SRPAuthBadDataFromServer
+ SRPAuthConnectionError
+ SRPAuthJSONDecodeError
+ SRPAuthBadPassword
+
+ :param salt_B: salt and B parameters for the username
+ :type salt_B: tuple
+ :param username: username for this session
+ :type username: str
+
+ :return: the M2 SRP parameter
+ :rtype: str
+ """
+ logger.debug("Processing challenge...")
+ try:
+ salt, B = salt_B
+ unhex_salt = self._safe_unhexlify(salt)
+ unhex_B = self._safe_unhexlify(B)
+ except (TypeError, ValueError) as e:
+ logger.error("Bad data from server: %r" % (e,))
+ raise SRPAuthBadDataFromServer(
+ self.tr("The data sent from the server had errors"))
+ M = self._srp_user.process_challenge(unhex_salt, unhex_B)
+
+ auth_url = "%s/%s/%s/%s" % (self._provider_config.get_api_uri(),
+ self._provider_config.
+ get_api_version(),
+ "sessions",
+ username)
+
+ auth_data = {
+ self.CLIENT_AUTH_KEY: binascii.hexlify(M)
+ }
+
+ try:
+ auth_result = self._session.put(auth_url,
+ data=auth_data,
+ verify=self._provider_config.
+ get_ca_cert_path(),
+ timeout=REQUEST_TIMEOUT)
+ except requests.exceptions.ConnectionError as e:
+ logger.error("No connection made (HAMK): %r" % (e,))
+ raise SRPAuthConnectionError(self.tr("Could not connect to "
+ "the server"))
+
+ try:
+ content, mtime = reqhelper.get_content(auth_result)
+ except JSONDecodeError:
+ raise SRPAuthJSONDecodeError("Bad JSON content in auth result")
+
+ if auth_result.status_code == 422:
+ error = ""
+ try:
+ error = json.loads(content).get("errors", "")
+ except ValueError:
+ logger.error("Problem parsing the received response: %s"
+ % (content,))
+ except AttributeError:
+ logger.error("Expecting a dict but something else was "
+ "received: %s", (content,))
+ logger.error("[%s] Wrong password (HAMK): [%s]" %
+ (auth_result.status_code, error))
+ raise SRPAuthBadPassword(self.tr("Wrong password"))
+
+ if auth_result.status_code not in (200,):
+ logger.error("No valid response (HAMK): "
+ "Status code = %s. Content = %r" %
+ (auth_result.status_code, content))
+ raise SRPAuthBadStatusCode(self.tr("Unknown error (%s)") %
+ (auth_result.status_code,))
+
+ return json.loads(content)
+
+ def _extract_data(self, json_content):
+ """
+ Extracts the necessary parameters from json_content (M2,
+ id, token)
+
+ Might raise SRPAuthenticationError based:
+ SRPBadDataFromServer
+
+ :param json_content: Data received from the server
+ :type json_content: dict
+ """
+ try:
+ M2 = json_content.get("M2", None)
+ uid = json_content.get("id", None)
+ token = json_content.get("token", None)
+ except Exception as e:
+ logger.error(e)
+ raise SRPAuthBadDataFromServer("Something went wrong with the "
+ "login")
+
+ self.set_uid(uid)
+ self.set_token(token)
+
+ if M2 is None or self.get_uid() is None:
+ logger.error("Something went wrong. Content = %r" %
+ (json_content,))
+ raise SRPAuthBadDataFromServer(self.tr("Problem getting data "
+ "from server"))
+
+ events_signal(
+ proto.CLIENT_UID, content=uid,
+ reqcbk=lambda req, res: None) # make the rpc call async
+
+ return M2
+
+ def _verify_session(self, M2):
+ """
+ Verifies the session based on the M2 parameter. If the
+ verification succeeds, it sets the session_id for this
+ session
+
+ Might raise SRPAuthenticationError based:
+ SRPAuthBadDataFromServer
+ SRPAuthVerificationFailed
+
+ :param M2: M2 SRP parameter
+ :type M2: str
+ """
+ logger.debug("Verifying session...")
+ try:
+ unhex_M2 = self._safe_unhexlify(M2)
+ except TypeError:
+ logger.error("Bad data from server (HAWK)")
+ raise SRPAuthBadDataFromServer(self.tr("Bad data from server"))
+
+ self._srp_user.verify_session(unhex_M2)
+
+ if not self._srp_user.authenticated():
+ logger.error("Auth verification failed")
+ raise SRPAuthVerificationFailed(self.tr("Auth verification "
+ "failed"))
+ logger.debug("Session verified.")
+
+ session_id = self._session.cookies.get(self.SESSION_ID_KEY, None)
+ if not session_id:
+ logger.error("Bad cookie from server (missing _session_id)")
+ raise SRPAuthNoSessionId(self.tr("Session cookie "
+ "verification "
+ "failed"))
+
+ events_signal(
+ proto.CLIENT_SESSION_ID, content=session_id,
+ reqcbk=lambda req, res: None) # make the rpc call async
+
+ self.set_session_id(session_id)
+
+ def _threader(self, cb, res, *args, **kwargs):
+ return threads.deferToThread(cb, res, *args, **kwargs)
+
+ def authenticate(self, username, password):
+ """
+ Executes the whole authentication process for a user
+
+ Might raise SRPAuthenticationError
+
+ :param username: username for this session
+ :type username: str
+ :param password: password for this user
+ :type password: str
+
+ :returns: A defer on a different thread
+ :rtype: twisted.internet.defer.Deferred
+ """
+ leap_assert(self.get_session_id() is None, "Already logged in")
+
+ d = threads.deferToThread(self._authentication_preprocessing,
+ username=username,
+ password=password)
+
+ d.addCallback(
+ partial(self._threader,
+ self._start_authentication),
+ username=username)
+ d.addCallback(
+ partial(self._threader,
+ self._process_challenge),
+ username=username)
+ d.addCallback(
+ partial(self._threader,
+ self._extract_data))
+ d.addCallback(partial(self._threader,
+ self._verify_session))
+
+ return d
+
+ def logout(self):
+ """
+ Logs out the current session.
+ Expects a session_id to exists, might raise AssertionError
+ """
+ logger.debug("Starting logout...")
+
+ leap_assert(self.get_session_id(),
+ "Cannot logout an unexisting session")
+
+ logout_url = "%s/%s/%s/" % (self._provider_config.get_api_uri(),
+ self._provider_config.
+ get_api_version(),
+ "sessions")
+ try:
+ self._session.delete(logout_url,
+ data=self.get_session_id(),
+ verify=self._provider_config.
+ get_ca_cert_path(),
+ timeout=REQUEST_TIMEOUT)
+ except Exception as e:
+ logger.warning("Something went wrong with the logout: %r" %
+ (e,))
+
+ self.set_session_id(None)
+ self.set_uid(None)
+ # Also reset the session
+ self._session = self._fetcher.session()
+ logger.debug("Successfully logged out.")
+
+ def set_session_id(self, session_id):
+ QtCore.QMutexLocker(self._session_id_lock)
+ self._session_id = session_id
+
+ def get_session_id(self):
+ QtCore.QMutexLocker(self._session_id_lock)
+ return self._session_id
+
+ def set_uid(self, uid):
+ QtCore.QMutexLocker(self._uid_lock)
+ self._uid = uid
+
+ def get_uid(self):
+ QtCore.QMutexLocker(self._uid_lock)
+ return self._uid
+
+ def set_token(self, token):
+ QtCore.QMutexLocker(self._token_lock)
+ self._token = token
+
+ def get_token(self):
+ QtCore.QMutexLocker(self._token_lock)
+ return self._token
+
+ __instance = None
+
+ authentication_finished = QtCore.Signal(bool, str)
+ logout_finished = QtCore.Signal(bool, str)
+
+ def __init__(self, provider_config):
+ """
+ Creates a singleton instance if needed
+ """
+ QtCore.QObject.__init__(self)
+
+ # Check whether we already have an instance
+ if SRPAuth.__instance is None:
+ # Create and remember instance
+ SRPAuth.__instance = SRPAuth.__impl(provider_config)
+
+ # Store instance reference as the only member in the handle
+ self.__dict__['_SRPAuth__instance'] = SRPAuth.__instance
+
+ def authenticate(self, username, password):
+ """
+ Executes the whole authentication process for a user
+
+ Might raise SRPAuthenticationError based
+
+ :param username: username for this session
+ :type username: str
+ :param password: password for this user
+ :type password: str
+ """
+
+ d = self.__instance.authenticate(username, password)
+ d.addCallback(self._gui_notify)
+ d.addErrback(self._errback)
+ return d
+
+ def _gui_notify(self, _):
+ """
+ Callback that notifies the UI with the proper signal.
+
+ :param _: IGNORED, output from the previous callback (None)
+ :type _: IGNORED
+ """
+ logger.debug("Successful login!")
+ self.authentication_finished.emit(True, self.tr("Succeeded"))
+
+ def _errback(self, failure):
+ """
+ General errback for the whole login process. Will notify the
+ UI with the proper signal.
+
+ :param failure: Failure object captured from a callback.
+ :type failure: twisted.python.failure.Failure
+ """
+ logger.error("Error logging in %s" % (failure,))
+ self.authentication_finished.emit(False, "%s" % (failure.value,))
+ failure.trap(Exception)
+
+ def get_session_id(self):
+ return self.__instance.get_session_id()
+
+ def get_uid(self):
+ return self.__instance.get_uid()
+
+ def get_token(self):
+ return self.__instance.get_token()
+
+ def logout(self):
+ """
+ Logs out the current session.
+ Expects a session_id to exists, might raise AssertionError
+ """
+ try:
+ self.__instance.logout()
+ self.logout_finished.emit(True, self.tr("Succeeded"))
+ return True
+ except Exception as e:
+ self.logout_finished.emit(False, "%s" % (e,))
+ return False
diff --git a/src/leap/bitmask/crypto/srpregister.py b/src/leap/bitmask/crypto/srpregister.py
new file mode 100644
index 00000000..c69294d7
--- /dev/null
+++ b/src/leap/bitmask/crypto/srpregister.py
@@ -0,0 +1,168 @@
+# -*- coding: utf-8 -*-
+# srpregister.py
+# Copyright (C) 2013 LEAP
+#
+# 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/>.
+
+import binascii
+import logging
+
+import requests
+import srp
+
+from PySide import QtCore
+from urlparse import urlparse
+
+from leap.bitmask.config.providerconfig import ProviderConfig
+from leap.bitmask.util.constants import SIGNUP_TIMEOUT
+from leap.common.check import leap_assert, leap_assert_type
+
+logger = logging.getLogger(__name__)
+
+
+class SRPRegister(QtCore.QObject):
+ """
+ Registers a user to a specific provider using SRP
+ """
+
+ USER_LOGIN_KEY = 'user[login]'
+ USER_VERIFIER_KEY = 'user[password_verifier]'
+ USER_SALT_KEY = 'user[password_salt]'
+
+ registration_finished = QtCore.Signal(bool, object)
+
+ def __init__(self,
+ provider_config=None,
+ register_path="users"):
+ """
+ Constructor
+
+ :param provider_config: provider configuration instance,
+ properly loaded
+ :type privider_config: ProviderConfig
+ :param register_path: webapp path for registering users
+ :type register_path; str
+ """
+ QtCore.QObject.__init__(self)
+ leap_assert(provider_config, "Please provide a provider")
+ leap_assert_type(provider_config, ProviderConfig)
+
+ self._provider_config = provider_config
+
+ # **************************************************** #
+ # Dependency injection helpers, override this for more
+ # granular testing
+ self._fetcher = requests
+ self._srp = srp
+ self._hashfun = self._srp.SHA256
+ self._ng = self._srp.NG_1024
+ # **************************************************** #
+
+ parsed_url = urlparse(provider_config.get_api_uri())
+ self._provider = parsed_url.hostname
+ self._port = parsed_url.port
+ if self._port is None:
+ self._port = "443"
+
+ self._register_path = register_path
+
+ self._session = self._fetcher.session()
+
+ def _get_registration_uri(self):
+ """
+ Returns the URI where the register request should be made for
+ the provider
+
+ :rtype: str
+ """
+
+ uri = "https://%s:%s/%s/%s" % (
+ self._provider,
+ self._port,
+ self._provider_config.get_api_version(),
+ self._register_path)
+
+ return uri
+
+ def register_user(self, username, password):
+ """
+ Registers a user with the validator based on the password provider
+
+ :param username: username to register
+ :type username: str
+ :param password: password for this username
+ :type password: str
+
+ :rtype: tuple
+ :rparam: (ok, request)
+ """
+ salt, verifier = self._srp.create_salted_verification_key(
+ username,
+ password,
+ self._hashfun,
+ self._ng)
+
+ user_data = {
+ self.USER_LOGIN_KEY: username,
+ self.USER_VERIFIER_KEY: binascii.hexlify(verifier),
+ self.USER_SALT_KEY: binascii.hexlify(salt)
+ }
+
+ uri = self._get_registration_uri()
+
+ logger.debug('Post to uri: %s' % uri)
+ logger.debug("Will try to register user = %s" % (username,))
+
+ ok = False
+ # This should be None, but we don't like when PySide segfaults,
+ # so it something else.
+ # To reproduce it, just do:
+ # self.registration_finished.emit(False, None)
+ req = []
+ try:
+ req = self._session.post(uri,
+ data=user_data,
+ timeout=SIGNUP_TIMEOUT,
+ verify=self._provider_config.
+ get_ca_cert_path())
+
+ except (requests.exceptions.SSLError,
+ requests.exceptions.ConnectionError) as exc:
+ logger.error(exc.message)
+ ok = False
+ else:
+ ok = req.ok
+
+ self.registration_finished.emit(ok, req)
+ return ok
+
+
+if __name__ == "__main__":
+ logger = logging.getLogger(name='leap')
+ logger.setLevel(logging.DEBUG)
+ console = logging.StreamHandler()
+ console.setLevel(logging.DEBUG)
+ formatter = logging.Formatter(
+ '%(asctime)s '
+ '- %(name)s - %(levelname)s - %(message)s')
+ console.setFormatter(formatter)
+ logger.addHandler(console)
+
+ provider = ProviderConfig()
+
+ if provider.load("leap/providers/bitmask.net/provider.json"):
+ register = SRPRegister(provider_config=provider)
+ print "Registering user..."
+ print register.register_user("test1", "sarasaaaa")
+ print register.register_user("test2", "sarasaaaa")
diff --git a/src/leap/bitmask/crypto/tests/__init__.py b/src/leap/bitmask/crypto/tests/__init__.py
new file mode 100644
index 00000000..7f118735
--- /dev/null
+++ b/src/leap/bitmask/crypto/tests/__init__.py
@@ -0,0 +1,16 @@
+# -*- coding: utf-8 -*-
+# __init__.py
+# Copyright (C) 2013 LEAP
+#
+# 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/>.
diff --git a/src/leap/bitmask/crypto/tests/eip-service.json b/src/leap/bitmask/crypto/tests/eip-service.json
new file mode 100644
index 00000000..24df42a2
--- /dev/null
+++ b/src/leap/bitmask/crypto/tests/eip-service.json
@@ -0,0 +1,43 @@
+{
+ "gateways": [
+ {
+ "capabilities": {
+ "adblock": false,
+ "filter_dns": false,
+ "limited": true,
+ "ports": [
+ "1194",
+ "443",
+ "53",
+ "80"
+ ],
+ "protocols": [
+ "tcp",
+ "udp"
+ ],
+ "transport": [
+ "openvpn"
+ ],
+ "user_ips": false
+ },
+ "host": "harrier.cdev.bitmask.net",
+ "ip_address": "199.254.238.50",
+ "location": "seattle__wa"
+ }
+ ],
+ "locations": {
+ "seattle__wa": {
+ "country_code": "US",
+ "hemisphere": "N",
+ "name": "Seattle, WA",
+ "timezone": "-7"
+ }
+ },
+ "openvpn_configuration": {
+ "auth": "SHA1",
+ "cipher": "AES-128-CBC",
+ "tls-cipher": "DHE-RSA-AES128-SHA"
+ },
+ "serial": 1,
+ "version": 1
+} \ No newline at end of file
diff --git a/src/leap/bitmask/crypto/tests/fake_provider.py b/src/leap/bitmask/crypto/tests/fake_provider.py
new file mode 100755
index 00000000..54af485d
--- /dev/null
+++ b/src/leap/bitmask/crypto/tests/fake_provider.py
@@ -0,0 +1,376 @@
+#!/usr/bin/env python
+# -*- coding: utf-8 -*-
+# fake_provider.py
+# Copyright (C) 2013 LEAP
+#
+# 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/>.
+"""A server faking some of the provider resources and apis,
+used for testing Leap Client requests
+
+It needs that you create a subfolder named 'certs',
+and that you place the following files:
+
+XXX check if in use
+
+[ ] test-openvpn.pem
+[ ] test-provider.json
+[ ] test-eip-service.json
+"""
+import binascii
+import json
+import os
+import sys
+import time
+
+import srp
+
+from OpenSSL import SSL
+
+from zope.interface import Interface, Attribute, implements
+
+from twisted.web.server import Site, Request
+from twisted.web.static import File, Data
+from twisted.web.resource import Resource
+from twisted.internet import reactor
+
+from leap.common.testing.https_server import where
+
+# See
+# http://twistedmatrix.com/documents/current/web/howto/web-in-60/index.html
+# for more examples
+
+"""
+Testing the FAKE_API:
+#####################
+
+ 1) register an user
+ >> curl -d "user[login]=me" -d "user[password_salt]=foo" \
+ -d "user[password_verifier]=beef" http://localhost:8000/1/users
+ << {"errors": null}
+
+ 2) check that if you try to register again, it will fail:
+ >> curl -d "user[login]=me" -d "user[password_salt]=foo" \
+ -d "user[password_verifier]=beef" http://localhost:8000/1/users
+ << {"errors": {"login": "already taken!"}}
+
+"""
+
+# Globals to mock user/sessiondb
+
+_USERDB = {}
+_SESSIONDB = {}
+
+_here = os.path.split(__file__)[0]
+
+
+safe_unhexlify = lambda x: binascii.unhexlify(x) \
+ if (len(x) % 2 == 0) else binascii.unhexlify('0' + x)
+
+
+class IUser(Interface):
+ """
+ Defines the User Interface
+ """
+ login = Attribute("User login.")
+ salt = Attribute("Password salt.")
+ verifier = Attribute("Password verifier.")
+ session = Attribute("Session.")
+ svr = Attribute("Server verifier.")
+
+
+class User(object):
+ """
+ User object.
+ We store it in our simple session mocks
+ """
+
+ implements(IUser)
+
+ def __init__(self, login, salt, verifier):
+ self.login = login
+ self.salt = salt
+ self.verifier = verifier
+ self.session = None
+ self.svr = None
+
+ def set_server_verifier(self, svr):
+ """
+ Adds a svr verifier object to this
+ User instance
+ """
+ self.svr = svr
+
+ def set_session(self, session):
+ """
+ Adds this instance of User to the
+ global session dict
+ """
+ _SESSIONDB[session] = self
+ self.session = session
+
+
+class FakeUsers(Resource):
+ """
+ Resource that handles user registration.
+ """
+
+ def __init__(self, name):
+ self.name = name
+
+ def render_POST(self, request):
+ """
+ Handles POST to the users api resource
+ Simulates a login.
+ """
+ args = request.args
+
+ login = args['user[login]'][0]
+ salt = args['user[password_salt]'][0]
+ verifier = args['user[password_verifier]'][0]
+
+ if login in _USERDB:
+ request.setResponseCode(422)
+ return "%s\n" % json.dumps(
+ {'errors': {'login': 'already taken!'}})
+
+ print '[server]', login, verifier, salt
+ user = User(login, salt, verifier)
+ _USERDB[login] = user
+ return json.dumps({'errors': None})
+
+
+def getSession(self, sessionInterface=None):
+ """
+ we overwrite twisted.web.server.Request.getSession method to
+ put the right cookie name in place
+ """
+ if not self.session:
+ #cookiename = b"_".join([b'TWISTED_SESSION'] + self.sitepath)
+ cookiename = b"_".join([b'_session_id'] + self.sitepath)
+ sessionCookie = self.getCookie(cookiename)
+ if sessionCookie:
+ try:
+ self.session = self.site.getSession(sessionCookie)
+ except KeyError:
+ pass
+ # if it still hasn't been set, fix it up.
+ if not self.session:
+ self.session = self.site.makeSession()
+ self.addCookie(cookiename, self.session.uid, path=b'/')
+ self.session.touch()
+ if sessionInterface:
+ return self.session.getComponent(sessionInterface)
+ return self.session
+
+
+def get_user(request):
+ """
+ Returns user from the session dict
+ """
+ login = request.args.get('login')
+ if login:
+ user = _USERDB.get(login[0], None)
+ if user:
+ return user
+
+ request.getSession = getSession.__get__(request, Request)
+ session = request.getSession()
+
+ user = _SESSIONDB.get(session, None)
+ return user
+
+
+class FakeSession(Resource):
+ def __init__(self, name):
+ """
+ Initializes session
+ """
+ self.name = name
+
+ def render_GET(self, request):
+ """
+ Handles GET requests.
+ """
+ return "%s\n" % json.dumps({'errors': None})
+
+ def render_POST(self, request):
+ """
+ Handles POST requests.
+ """
+ user = get_user(request)
+
+ if not user:
+ # XXX get real error from demo provider
+ return json.dumps({'errors': 'no such user'})
+
+ A = request.args['A'][0]
+
+ _A = safe_unhexlify(A)
+ _salt = safe_unhexlify(user.salt)
+ _verifier = safe_unhexlify(user.verifier)
+
+ svr = srp.Verifier(
+ user.login,
+ _salt,
+ _verifier,
+ _A,
+ hash_alg=srp.SHA256,
+ ng_type=srp.NG_1024)
+
+ s, B = svr.get_challenge()
+
+ _B = binascii.hexlify(B)
+
+ print '[server] login = %s' % user.login
+ print '[server] salt = %s' % user.salt
+ print '[server] len(_salt) = %s' % len(_salt)
+ print '[server] vkey = %s' % user.verifier
+ print '[server] len(vkey) = %s' % len(_verifier)
+ print '[server] s = %s' % binascii.hexlify(s)
+ print '[server] B = %s' % _B
+ print '[server] len(B) = %s' % len(_B)
+
+ # override Request.getSession
+ request.getSession = getSession.__get__(request, Request)
+ session = request.getSession()
+
+ user.set_session(session)
+ user.set_server_verifier(svr)
+
+ # yep, this is tricky.
+ # some things are *already* unhexlified.
+ data = {
+ 'salt': user.salt,
+ 'B': _B,
+ 'errors': None}
+
+ return json.dumps(data)
+
+ def render_PUT(self, request):
+ """
+ Handles PUT requests.
+ """
+ # XXX check session???
+ user = get_user(request)
+
+ if not user:
+ print '[server] NO USER'
+ return json.dumps({'errors': 'no such user'})
+
+ data = request.content.read()
+ auth = data.split("client_auth=")
+ M = auth[1] if len(auth) > 1 else None
+ # if not H, return
+ if not M:
+ return json.dumps({'errors': 'no M proof passed by client'})
+
+ svr = user.svr
+ HAMK = svr.verify_session(binascii.unhexlify(M))
+ if HAMK is None:
+ print '[server] verification failed!!!'
+ raise Exception("Authentication failed!")
+ #import ipdb;ipdb.set_trace()
+
+ assert svr.authenticated()
+ print "***"
+ print '[server] User successfully authenticated using SRP!'
+ print "***"
+
+ return json.dumps(
+ {'M2': binascii.hexlify(HAMK),
+ 'id': '9c943eb9d96a6ff1b7a7030bdeadbeef',
+ 'errors': None})
+
+
+class API_Sessions(Resource):
+ """
+ Top resource for the API v1
+ """
+ def getChild(self, name, request):
+ return FakeSession(name)
+
+
+class FileModified(File):
+ def render_GET(self, request):
+ since = request.getHeader('if-modified-since')
+ if since:
+ tsince = time.strptime(since.replace(" GMT", ""))
+ tfrom = time.strptime(time.ctime(os.path.getmtime(self.path)))
+ if tfrom > tsince:
+ return File.render_GET(self, request)
+ else:
+ request.setResponseCode(304)
+ return ""
+ return File.render_GET(self, request)
+
+
+class OpenSSLServerContextFactory(object):
+
+ def getContext(self):
+ """
+ Create an SSL context.
+ """
+ ctx = SSL.Context(SSL.SSLv23_METHOD)
+ #ctx = SSL.Context(SSL.TLSv1_METHOD)
+ ctx.use_certificate_file(where('leaptestscert.pem'))
+ ctx.use_privatekey_file(where('leaptestskey.pem'))
+
+ return ctx
+
+
+def get_provider_factory():
+ """
+ Instantiates a Site that serves the resources
+ that we expect from a valid provider.
+ Listens on:
+ * port 8000 for http connections
+ * port 8443 for https connections
+
+ :rparam: factory for a site
+ :rtype: Site instance
+ """
+ root = Data("", "")
+ root.putChild("", root)
+ root.putChild("provider.json", FileModified(
+ os.path.join(_here,
+ "test_provider.json")))
+ config = Resource()
+ config.putChild(
+ "eip-service.json",
+ FileModified(
+ os.path.join(_here, "eip-service.json")))
+ apiv1 = Resource()
+ apiv1.putChild("config", config)
+ apiv1.putChild("sessions", API_Sessions())
+ apiv1.putChild("users", FakeUsers(None))
+ apiv1.putChild("cert", FileModified(
+ os.path.join(_here,
+ 'openvpn.pem')))
+ root.putChild("1", apiv1)
+
+ factory = Site(root)
+ return factory
+
+
+if __name__ == "__main__":
+
+ from twisted.python import log
+ log.startLogging(sys.stdout)
+
+ factory = get_provider_factory()
+
+ # regular http (for debugging with curl)
+ reactor.listenTCP(8000, factory)
+ reactor.listenSSL(8443, factory, OpenSSLServerContextFactory())
+ reactor.run()
diff --git a/src/leap/bitmask/crypto/tests/openvpn.pem b/src/leap/bitmask/crypto/tests/openvpn.pem
new file mode 100644
index 00000000..a95e9370
--- /dev/null
+++ b/src/leap/bitmask/crypto/tests/openvpn.pem
@@ -0,0 +1,33 @@
+-----BEGIN CERTIFICATE-----
+MIIFtTCCA52gAwIBAgIJAIGJ8Dg+DtemMA0GCSqGSIb3DQEBBQUAMEUxCzAJBgNV
+BAYTAkFVMRMwEQYDVQQIEwpTb21lLVN0YXRlMSEwHwYDVQQKExhJbnRlcm5ldCBX
+aWRnaXRzIFB0eSBMdGQwHhcNMTMwNjI2MjAyMDIyWhcNMTgwNjI2MjAyMDIyWjBF
+MQswCQYDVQQGEwJBVTETMBEGA1UECBMKU29tZS1TdGF0ZTEhMB8GA1UEChMYSW50
+ZXJuZXQgV2lkZ2l0cyBQdHkgTHRkMIICIjANBgkqhkiG9w0BAQEFAAOCAg8AMIIC
+CgKCAgEAxJaN0lWjFu+3j48c0WG8BvmPUf026Xli5d5NE4EjGsirwfre0oTeWZT9
+WRxqLGd2wDh6Mc9r6UqH6dwqLZKbsgwB5zI2lag7UWFttJF1U1c6AJynhaLMoy73
+sL9USTmQ57iYRFrVP/nGj9/L6I1XnV6midPi7a5aZreH9q8dWaAhmc9eFDU+Y4vS
+sTFS6aomajLrI6YWo5toKqLq8IMryD03IM78a7gJtLgfWs+pYZRUBlM5JaYX98eX
+mVPAYYH9krWxLVN3hTt1ngECzK+epo275zQJh960/2fNCfVJSXqSXcficLs+bR7t
+FEkNuOP1hFV6LuoLL+k5Su+hp5kXMYZTvYYDpW4nPJoBdSG1w5O5IxO6zh+9VLB7
+oLrlgoyWvBoou5coCBpZVU6UyWcOx58kuZF8wNr0GgdvWAFwOGVuVG5jmcVdhaKC
+0C8NxHrxlhcrcp0zwtDaOxfmZfcxiXs35iwUip5vS18Nv+XBK8ad9T79Ox8nSzP3
+RGPVDpExz7gPbZglqSe47XBIk0ZuIzgOgYpJj4JrpoewoIYb+OmUgI7UZjoGsMrV
++B2BqOKs7kF0HW3i5bR9YAi0ZYvnhQgjBtwCKm4zvLqwuPZHz9VWgIk6uezgStCP
+WyzQ8IcopK49fOjcKa6JT5JRU+27paIZf1BkQsTkJy/Nti4TvwMCAwEAAaOBpzCB
+pDAdBgNVHQ4EFgQUEgXSd3Yl3xAzbkWa7xeNe27d99cwdQYDVR0jBG4wbIAUEgXS
+d3Yl3xAzbkWa7xeNe27d99ehSaRHMEUxCzAJBgNVBAYTAkFVMRMwEQYDVQQIEwpT
+b21lLVN0YXRlMSEwHwYDVQQKExhJbnRlcm5ldCBXaWRnaXRzIFB0eSBMdGSCCQCB
+ifA4Pg7XpjAMBgNVHRMEBTADAQH/MA0GCSqGSIb3DQEBBQUAA4ICAQA6Vl9Ve4Qe
+ewzXAxr0BabFRhtIuF7DV+/niT46qJhW2KgYe6rwZqdAhEbgH3kTPJ5JmmcUnAEH
+nmrfoku/YAb5ObfdHUACsHy4cvSvFwBUQ9vXP6+oOFJhrGW4uzRI2pHGvnqB3lQ0
+JEPmPwduBCI5reRYauPbd4Wl4VhLGrjELb4JQZL24Q5ehXMnv415m7+aMkLzT2IA
+p6B2xgRR+JAeUdyCNOV1f5AqJWyAUJPWGR0e1OTKNfc49+2skK0NmzrpGsoktSHa
+uN6vGBCVGiZh7BTYblWMG5q9Am7idcdmC2fdpIf5yj7CKzV7WIPxPs0I7TuRcr41
+pUBLCAElcyCPB89lySol2BDs4gk4wZs4y2shUs3o0+mIpw/6o8tQF/9IL8ALkLqr
+q9SuND7O1RXcg74o3HeVmRKtoI/KdgaVhJ0rFvcq83ftfu3KMyWB6SOKOu6ZYON8
+AcSjsDDpnDrwGFvjAYHiTkS9NaaJC1/g7Y6jjhxmbTkXPA6V8MvLKQiOvqk/9gCh
+85FHsFkElIYnH6fbHIRxg20cnqmddTd+H5HgBIlhiKWuydtuoQFwzR/D3ypgLBaB
+OWLcBP7I+RYhKlJFIWnfiyB0xbyI4W/UfL8p8jQI8TE9oIlm3WqxJXfebDEDEstj
+8nS4Fb3G5Wr4pZMjfbtmBSAgHeWH6B90jg==
+-----END CERTIFICATE-----
diff --git a/src/leap/bitmask/crypto/tests/test_provider.json b/src/leap/bitmask/crypto/tests/test_provider.json
new file mode 100644
index 00000000..c37bef8f
--- /dev/null
+++ b/src/leap/bitmask/crypto/tests/test_provider.json
@@ -0,0 +1,15 @@
+{
+ "api_uri": "https://localhost:8443",
+ "api_version": "1",
+ "ca_cert_fingerprint": "SHA256: 0f17c033115f6b76ff67871872303ff65034efe7dd1b910062ca323eb4da5c7e",
+ "ca_cert_uri": "https://bitmask.net/ca.crt",
+ "default_language": "en",
+ "domain": "example.com",
+ "enrollment_policy": "open",
+ "name": {
+ "en": "Bitmask"
+ },
+ "services": [
+ "openvpn"
+ ]
+}
diff --git a/src/leap/bitmask/crypto/tests/test_srpauth.py b/src/leap/bitmask/crypto/tests/test_srpauth.py
new file mode 100644
index 00000000..043da15e
--- /dev/null
+++ b/src/leap/bitmask/crypto/tests/test_srpauth.py
@@ -0,0 +1,791 @@
+# -*- coding: utf-8 -*-
+# test_srpauth.py
+# Copyright (C) 2013 LEAP
+#
+# 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/crypto/srpauth.py
+"""
+try:
+ import unittest2 as unittest
+except ImportError:
+ import unittest
+import os
+import sys
+import binascii
+import requests
+import mock
+
+from functools import partial
+
+from mock import MagicMock
+from nose.twistedtools import reactor, deferred
+from twisted.python import log
+from twisted.internet import threads
+from requests.models import Response
+from simplejson.decoder import JSONDecodeError
+
+from leap.bitmask.config.providerconfig import ProviderConfig
+from leap.bitmask.crypto import srpregister, srpauth
+from leap.bitmask.crypto.tests import fake_provider
+from leap.bitmask.util.request_helpers import get_content
+from leap.common.testing.https_server import where
+
+log.startLogging(sys.stdout)
+
+
+def _get_capath():
+ return where("cacert.pem")
+
+_here = os.path.split(__file__)[0]
+
+
+class ImproperlyConfiguredError(Exception):
+ """
+ Raised if the test provider is missing configuration
+ """
+
+
+class SRPAuthTestCase(unittest.TestCase):
+ """
+ Tests for the SRPAuth class
+ """
+ __name__ = "SRPAuth tests"
+
+ def setUp(self):
+ """
+ Sets up this TestCase with a simple and faked provider instance:
+
+ * runs a threaded reactor
+ * loads a mocked ProviderConfig that points to the certs in the
+ leap.common.testing module.
+ """
+ factory = fake_provider.get_provider_factory()
+ http = reactor.listenTCP(0, factory)
+ https = reactor.listenSSL(
+ 0, factory,
+ fake_provider.OpenSSLServerContextFactory())
+ get_port = lambda p: p.getHost().port
+ self.http_port = get_port(http)
+ self.https_port = get_port(https)
+
+ provider = ProviderConfig()
+ provider.get_ca_cert_path = mock.create_autospec(
+ provider.get_ca_cert_path)
+ provider.get_ca_cert_path.return_value = _get_capath()
+
+ provider.get_api_uri = mock.create_autospec(
+ provider.get_api_uri)
+ provider.get_api_uri.return_value = self._get_https_uri()
+
+ loaded = provider.load(path=os.path.join(
+ _here, "test_provider.json"))
+ if not loaded:
+ raise ImproperlyConfiguredError(
+ "Could not load test provider config")
+ self.register = srpregister.SRPRegister(provider_config=provider)
+ self.provider = provider
+ self.TEST_USER = "register_test_auth"
+ self.TEST_PASS = "pass"
+
+ # Reset the singleton
+ srpauth.SRPAuth._SRPAuth__instance = None
+ self.auth = srpauth.SRPAuth(self.provider)
+ self.auth_backend = self.auth._SRPAuth__instance
+
+ self.old_post = self.auth_backend._session.post
+ self.old_put = self.auth_backend._session.put
+ self.old_delete = self.auth_backend._session.delete
+
+ self.old_start_auth = self.auth_backend._start_authentication
+ self.old_proc_challenge = self.auth_backend._process_challenge
+ self.old_extract_data = self.auth_backend._extract_data
+ self.old_verify_session = self.auth_backend._verify_session
+ self.old_auth_preproc = self.auth_backend._authentication_preprocessing
+ self.old_get_sid = self.auth_backend.get_session_id
+ self.old_cookie_get = self.auth_backend._session.cookies.get
+ self.old_auth = self.auth_backend.authenticate
+
+ def tearDown(self):
+ self.auth_backend._session.post = self.old_post
+ self.auth_backend._session.put = self.old_put
+ self.auth_backend._session.delete = self.old_delete
+
+ self.auth_backend._start_authentication = self.old_start_auth
+ self.auth_backend._process_challenge = self.old_proc_challenge
+ self.auth_backend._extract_data = self.old_extract_data
+ self.auth_backend._verify_session = self.old_verify_session
+ self.auth_backend._authentication_preprocessing = self.old_auth_preproc
+ self.auth_backend.get_session_id = self.old_get_sid
+ self.auth_backend._session.cookies.get = self.old_cookie_get
+ self.auth_backend.authenticate = self.old_auth
+
+ # helper methods
+
+ def _get_https_uri(self):
+ """
+ Returns a https uri with the right https port initialized
+ """
+ return "https://localhost:%s" % (self.https_port,)
+
+ # Auth tests
+
+ def _prepare_auth_test(self, code=200, side_effect=None):
+ """
+ Creates the needed defers to test several test situations. It
+ adds up to the auth preprocessing step.
+
+ :param code: status code for the response of POST in requests
+ :type code: int
+ :param side_effect: side effect triggered by the POST method
+ in requests
+ :type side_effect: some kind of Exception
+
+ :returns: the defer that is created
+ :rtype: defer.Deferred
+ """
+ res = Response()
+ res.status_code = code
+ self.auth_backend._session.post = mock.create_autospec(
+ self.auth_backend._session.post,
+ return_value=res,
+ side_effect=side_effect)
+
+ d = threads.deferToThread(self.register.register_user,
+ self.TEST_USER,
+ self.TEST_PASS)
+
+ def wrapper_preproc(*args):
+ return threads.deferToThread(
+ self.auth_backend._authentication_preprocessing,
+ self.TEST_USER, self.TEST_PASS)
+
+ d.addCallback(wrapper_preproc)
+
+ return d
+
+ def test_safe_unhexlify(self):
+ input_value = "somestring"
+ test_value = binascii.hexlify(input_value)
+ self.assertEqual(
+ self.auth_backend._safe_unhexlify(test_value),
+ input_value)
+
+ def test_safe_unhexlify_not_raises(self):
+ input_value = "somestring"
+ test_value = binascii.hexlify(input_value)[:-1]
+
+ with self.assertRaises(TypeError):
+ binascii.unhexlify(test_value)
+
+ self.auth_backend._safe_unhexlify(test_value)
+
+ def test_preprocessing_loads_a(self):
+ self.assertEqual(self.auth_backend._srp_a, None)
+ self.auth_backend._authentication_preprocessing("user", "pass")
+ self.assertIsNotNone(self.auth_backend._srp_a)
+ self.assertTrue(len(self.auth_backend._srp_a) > 0)
+
+ @deferred()
+ def test_start_authentication(self):
+ d = threads.deferToThread(self.register.register_user, self.TEST_USER,
+ self.TEST_PASS)
+
+ def wrapper_preproc(*args):
+ return threads.deferToThread(
+ self.auth_backend._authentication_preprocessing,
+ self.TEST_USER, self.TEST_PASS)
+
+ d.addCallback(wrapper_preproc)
+
+ def wrapper(_):
+ return threads.deferToThread(
+ self.auth_backend._start_authentication,
+ None, self.TEST_USER)
+
+ d.addCallback(wrapper)
+ return d
+
+ @deferred()
+ def test_start_authentication_fails_connerror(self):
+ d = self._prepare_auth_test(
+ side_effect=requests.exceptions.ConnectionError())
+
+ def wrapper(_):
+ with self.assertRaises(srpauth.SRPAuthConnectionError):
+ self.auth_backend._start_authentication(None, self.TEST_USER)
+
+ d.addCallback(partial(threads.deferToThread, wrapper))
+ return d
+
+ @deferred()
+ def test_start_authentication_fails_any_error(self):
+ d = self._prepare_auth_test(side_effect=Exception())
+
+ def wrapper(_):
+ with self.assertRaises(srpauth.SRPAuthenticationError):
+ self.auth_backend._start_authentication(None, self.TEST_USER)
+
+ d.addCallback(partial(threads.deferToThread, wrapper))
+ return d
+
+ @deferred()
+ def test_start_authentication_fails_unknown_user(self):
+ d = self._prepare_auth_test(422)
+
+ def wrapper(_):
+ with self.assertRaises(srpauth.SRPAuthUnknownUser):
+ with mock.patch('leap.util.request_helpers.get_content',
+ new=mock.create_autospec(get_content)) as \
+ content:
+ content.return_value = ("{}", 0)
+
+ self.auth_backend._start_authentication(
+ None, self.TEST_USER)
+
+ d.addCallback(partial(threads.deferToThread, wrapper))
+ return d
+
+ @deferred()
+ def test_start_authentication_fails_errorcode(self):
+ d = self._prepare_auth_test(302)
+
+ def wrapper(_):
+ with self.assertRaises(srpauth.SRPAuthBadStatusCode):
+ with mock.patch('leap.util.request_helpers.get_content',
+ new=mock.create_autospec(get_content)) as \
+ content:
+ content.return_value = ("{}", 0)
+
+ self.auth_backend._start_authentication(None,
+ self.TEST_USER)
+
+ d.addCallback(partial(threads.deferToThread, wrapper))
+ return d
+
+ @deferred()
+ def test_start_authentication_fails_no_salt(self):
+ d = self._prepare_auth_test(200)
+
+ def wrapper(_):
+ with self.assertRaises(srpauth.SRPAuthNoSalt):
+ with mock.patch('leap.util.request_helpers.get_content',
+ new=mock.create_autospec(get_content)) as \
+ content:
+ content.return_value = ("{}", 0)
+
+ self.auth_backend._start_authentication(None,
+ self.TEST_USER)
+
+ d.addCallback(partial(threads.deferToThread, wrapper))
+ return d
+
+ @deferred()
+ def test_start_authentication_fails_no_B(self):
+ d = self._prepare_auth_test(200)
+
+ def wrapper(_):
+ with self.assertRaises(srpauth.SRPAuthNoB):
+ with mock.patch('leap.util.request_helpers.get_content',
+ new=mock.create_autospec(get_content)) as \
+ content:
+ content.return_value = ('{"salt": ""}', 0)
+
+ self.auth_backend._start_authentication(None,
+ self.TEST_USER)
+
+ d.addCallback(partial(threads.deferToThread, wrapper))
+ return d
+
+ @deferred()
+ def test_start_authentication_correct_saltb(self):
+ d = self._prepare_auth_test(200)
+
+ test_salt = "12345"
+ test_B = "67890"
+
+ def wrapper(_):
+ with mock.patch('leap.util.request_helpers.get_content',
+ new=mock.create_autospec(get_content)) as \
+ content:
+ content.return_value = ('{"salt":"%s", "B":"%s"}' % (test_salt,
+ test_B),
+ 0)
+
+ salt, B = self.auth_backend._start_authentication(
+ None,
+ self.TEST_USER)
+ self.assertEqual(salt, test_salt)
+ self.assertEqual(B, test_B)
+
+ d.addCallback(partial(threads.deferToThread, wrapper))
+ return d
+
+ def _prepare_auth_challenge(self):
+ """
+ Creates the needed defers to test several test situations. It
+ adds up to the start authentication step.
+
+ :returns: the defer that is created
+ :rtype: defer.Deferred
+ """
+ d = threads.deferToThread(self.register.register_user,
+ self.TEST_USER,
+ self.TEST_PASS)
+
+ def wrapper_preproc(*args):
+ return threads.deferToThread(
+ self.auth_backend._authentication_preprocessing,
+ self.TEST_USER, self.TEST_PASS)
+
+ d.addCallback(wrapper_preproc)
+
+ def wrapper_start(*args):
+ return threads.deferToThread(
+ self.auth_backend._start_authentication,
+ None, self.TEST_USER)
+
+ d.addCallback(wrapper_start)
+
+ return d
+
+ @deferred()
+ def test_process_challenge_wrong_saltb(self):
+ d = self._prepare_auth_challenge()
+
+ def wrapper(salt_B):
+ with self.assertRaises(srpauth.SRPAuthBadDataFromServer):
+ self.auth_backend._process_challenge("",
+ username=self.TEST_USER)
+
+ d.addCallback(partial(threads.deferToThread, wrapper))
+ return d
+
+ @deferred()
+ def test_process_challenge_requests_problem_raises(self):
+ d = self._prepare_auth_challenge()
+
+ self.auth_backend._session.put = mock.create_autospec(
+ self.auth_backend._session.put,
+ side_effect=requests.exceptions.ConnectionError())
+
+ def wrapper(salt_B):
+ with self.assertRaises(srpauth.SRPAuthConnectionError):
+ self.auth_backend._process_challenge(salt_B,
+ username=self.TEST_USER)
+
+ d.addCallback(partial(threads.deferToThread, wrapper))
+
+ return d
+
+ @deferred()
+ def test_process_challenge_json_decode_error(self):
+ d = self._prepare_auth_challenge()
+
+ def wrapper(salt_B):
+ with mock.patch('leap.util.request_helpers.get_content',
+ new=mock.create_autospec(get_content)) as \
+ content:
+ content.return_value = ("{", 0)
+ content.side_effect = JSONDecodeError("", "", 0)
+
+ with self.assertRaises(srpauth.SRPAuthJSONDecodeError):
+ self.auth_backend._process_challenge(
+ salt_B,
+ username=self.TEST_USER)
+
+ d.addCallback(partial(threads.deferToThread, wrapper))
+
+ return d
+
+ @deferred()
+ def test_process_challenge_bad_password(self):
+ d = self._prepare_auth_challenge()
+
+ res = Response()
+ res.status_code = 422
+ self.auth_backend._session.put = mock.create_autospec(
+ self.auth_backend._session.put,
+ return_value=res)
+
+ def wrapper(salt_B):
+ with mock.patch('leap.util.request_helpers.get_content',
+ new=mock.create_autospec(get_content)) as \
+ content:
+ content.return_value = ("", 0)
+ with self.assertRaises(srpauth.SRPAuthBadPassword):
+ self.auth_backend._process_challenge(
+ salt_B,
+ username=self.TEST_USER)
+
+ d.addCallback(partial(threads.deferToThread, wrapper))
+
+ return d
+
+ @deferred()
+ def test_process_challenge_bad_password2(self):
+ d = self._prepare_auth_challenge()
+
+ res = Response()
+ res.status_code = 422
+ self.auth_backend._session.put = mock.create_autospec(
+ self.auth_backend._session.put,
+ return_value=res)
+
+ def wrapper(salt_B):
+ with mock.patch('leap.util.request_helpers.get_content',
+ new=mock.create_autospec(get_content)) as \
+ content:
+ content.return_value = ("[]", 0)
+ with self.assertRaises(srpauth.SRPAuthBadPassword):
+ self.auth_backend._process_challenge(
+ salt_B,
+ username=self.TEST_USER)
+
+ d.addCallback(partial(threads.deferToThread, wrapper))
+
+ return d
+
+ @deferred()
+ def test_process_challenge_other_error_code(self):
+ d = self._prepare_auth_challenge()
+
+ res = Response()
+ res.status_code = 300
+ self.auth_backend._session.put = mock.create_autospec(
+ self.auth_backend._session.put,
+ return_value=res)
+
+ def wrapper(salt_B):
+ with mock.patch('leap.util.request_helpers.get_content',
+ new=mock.create_autospec(get_content)) as \
+ content:
+ content.return_value = ("{}", 0)
+ with self.assertRaises(srpauth.SRPAuthBadStatusCode):
+ self.auth_backend._process_challenge(
+ salt_B,
+ username=self.TEST_USER)
+
+ d.addCallback(partial(threads.deferToThread, wrapper))
+
+ return d
+
+ @deferred()
+ def test_process_challenge(self):
+ d = self._prepare_auth_challenge()
+
+ def wrapper(salt_B):
+ self.auth_backend._process_challenge(salt_B,
+ username=self.TEST_USER)
+
+ d.addCallback(partial(threads.deferToThread, wrapper))
+
+ return d
+
+ def test_extract_data_wrong_data(self):
+ with self.assertRaises(srpauth.SRPAuthBadDataFromServer):
+ self.auth_backend._extract_data(None)
+
+ with self.assertRaises(srpauth.SRPAuthBadDataFromServer):
+ self.auth_backend._extract_data("")
+
+ def test_extract_data_fails_on_wrong_data_from_server(self):
+ with self.assertRaises(srpauth.SRPAuthBadDataFromServer):
+ self.auth_backend._extract_data({})
+
+ with self.assertRaises(srpauth.SRPAuthBadDataFromServer):
+ self.auth_backend._extract_data({"M2": ""})
+
+ def test_extract_data_sets_uidtoken(self):
+ test_uid = "someuid"
+ test_m2 = "somem2"
+ test_token = "sometoken"
+ test_data = {
+ "M2": test_m2,
+ "id": test_uid,
+ "token": test_token
+ }
+ m2 = self.auth_backend._extract_data(test_data)
+
+ self.assertEqual(m2, test_m2)
+ self.assertEqual(self.auth_backend.get_uid(), test_uid)
+ self.assertEqual(self.auth_backend.get_uid(),
+ self.auth.get_uid())
+ self.assertEqual(self.auth_backend.get_token(), test_token)
+ self.assertEqual(self.auth_backend.get_token(),
+ self.auth.get_token())
+
+ def _prepare_verify_session(self):
+ """
+ Prepares the tests for verify session with needed steps
+ before. It adds up to the extract_data step.
+
+ :returns: The defer to chain to
+ :rtype: defer.Deferred
+ """
+ d = self._prepare_auth_challenge()
+
+ def wrapper_proc_challenge(salt_B):
+ return self.auth_backend._process_challenge(
+ salt_B,
+ username=self.TEST_USER)
+
+ def wrapper_extract_data(data):
+ return self.auth_backend._extract_data(data)
+
+ d.addCallback(partial(threads.deferToThread, wrapper_proc_challenge))
+ d.addCallback(partial(threads.deferToThread, wrapper_extract_data))
+
+ return d
+
+ @deferred()
+ def test_verify_session_unhexlifiable_m2(self):
+ d = self._prepare_verify_session()
+
+ def wrapper(M2):
+ with self.assertRaises(srpauth.SRPAuthBadDataFromServer):
+ self.auth_backend._verify_session("za") # unhexlifiable value
+
+ d.addCallback(wrapper)
+
+ return d
+
+ @deferred()
+ def test_verify_session_unverifiable_m2(self):
+ d = self._prepare_verify_session()
+
+ def wrapper(M2):
+ with self.assertRaises(srpauth.SRPAuthVerificationFailed):
+ # Correctly unhelifiable value, but not for verifying the
+ # session
+ self.auth_backend._verify_session("abc12")
+
+ d.addCallback(wrapper)
+
+ return d
+
+ @deferred()
+ def test_verify_session_fails_on_no_session_id(self):
+ d = self._prepare_verify_session()
+
+ def wrapper(M2):
+ self.auth_backend._session.cookies.get = mock.create_autospec(
+ self.auth_backend._session.cookies.get,
+ return_value=None)
+ with self.assertRaises(srpauth.SRPAuthNoSessionId):
+ self.auth_backend._verify_session(M2)
+
+ d.addCallback(wrapper)
+
+ return d
+
+ @deferred()
+ def test_verify_session_session_id(self):
+ d = self._prepare_verify_session()
+
+ test_session_id = "12345"
+
+ def wrapper(M2):
+ self.auth_backend._session.cookies.get = mock.create_autospec(
+ self.auth_backend._session.cookies.get,
+ return_value=test_session_id)
+ self.auth_backend._verify_session(M2)
+ self.assertEqual(self.auth_backend.get_session_id(),
+ test_session_id)
+ self.assertEqual(self.auth_backend.get_session_id(),
+ self.auth.get_session_id())
+
+ d.addCallback(wrapper)
+
+ return d
+
+ @deferred()
+ def test_verify_session(self):
+ d = self._prepare_verify_session()
+
+ def wrapper(M2):
+ self.auth_backend._verify_session(M2)
+
+ d.addCallback(wrapper)
+
+ return d
+
+ @deferred()
+ def test_authenticate(self):
+ self.auth_backend._authentication_preprocessing = mock.create_autospec(
+ self.auth_backend._authentication_preprocessing,
+ return_value=None)
+ self.auth_backend._start_authentication = mock.create_autospec(
+ self.auth_backend._start_authentication,
+ return_value=None)
+ self.auth_backend._process_challenge = mock.create_autospec(
+ self.auth_backend._process_challenge,
+ return_value=None)
+ self.auth_backend._extract_data = mock.create_autospec(
+ self.auth_backend._extract_data,
+ return_value=None)
+ self.auth_backend._verify_session = mock.create_autospec(
+ self.auth_backend._verify_session,
+ return_value=None)
+
+ d = self.auth_backend.authenticate(self.TEST_USER, self.TEST_PASS)
+
+ def check(*args):
+ self.auth_backend._authentication_preprocessing.\
+ assert_called_once_with(
+ username=self.TEST_USER,
+ password=self.TEST_PASS
+ )
+ self.auth_backend._start_authentication.assert_called_once_with(
+ None,
+ username=self.TEST_USER)
+ self.auth_backend._process_challenge.assert_called_once_with(
+ None,
+ username=self.TEST_USER)
+ self.auth_backend._extract_data.assert_called_once_with(
+ None)
+ self.auth_backend._verify_session.assert_called_once_with(None)
+
+ d.addCallback(check)
+
+ return d
+
+ @deferred()
+ def test_logout_fails_if_not_logged_in(self):
+
+ def wrapper(*args):
+ with self.assertRaises(AssertionError):
+ self.auth_backend.logout()
+
+ d = threads.deferToThread(wrapper)
+ return d
+
+ @deferred()
+ def test_logout_traps_delete(self):
+ self.auth_backend.get_session_id = mock.create_autospec(
+ self.auth_backend.get_session_id,
+ return_value="1234")
+ self.auth_backend._session.delete = mock.create_autospec(
+ self.auth_backend._session.delete,
+ side_effect=Exception())
+
+ def wrapper(*args):
+ self.auth_backend.logout()
+
+ d = threads.deferToThread(wrapper)
+ return d
+
+ @deferred()
+ def test_logout_clears(self):
+ self.auth_backend._session_id = "1234"
+
+ def wrapper(*args):
+ old_session = self.auth_backend._session
+ self.auth_backend.logout()
+ self.assertIsNone(self.auth_backend.get_session_id())
+ self.assertIsNone(self.auth_backend.get_uid())
+ self.assertNotEqual(old_session, self.auth_backend._session)
+
+ d = threads.deferToThread(wrapper)
+ return d
+
+
+class SRPAuthSingletonTestCase(unittest.TestCase):
+ def setUp(self):
+ self.old_auth = srpauth.SRPAuth._SRPAuth__impl.authenticate
+
+ def tearDown(self):
+ srpauth.SRPAuth._SRPAuth__impl.authenticate = self.old_auth
+
+ def test_singleton(self):
+ obj1 = srpauth.SRPAuth(ProviderConfig())
+ obj2 = srpauth.SRPAuth(ProviderConfig())
+ self.assertEqual(obj1._SRPAuth__instance, obj2._SRPAuth__instance)
+
+ @deferred()
+ def test_authenticate_notifies_gui(self):
+ auth = srpauth.SRPAuth(ProviderConfig())
+ auth._SRPAuth__instance.authenticate = mock.create_autospec(
+ auth._SRPAuth__instance.authenticate,
+ return_value=threads.deferToThread(lambda: None))
+ auth._gui_notify = mock.create_autospec(
+ auth._gui_notify)
+
+ d = auth.authenticate("", "")
+
+ def check(*args):
+ auth._gui_notify.assert_called_once_with(None)
+
+ d.addCallback(check)
+ return d
+
+ @deferred()
+ def test_authenticate_errsback(self):
+ auth = srpauth.SRPAuth(ProviderConfig())
+ auth._SRPAuth__instance.authenticate = mock.create_autospec(
+ auth._SRPAuth__instance.authenticate,
+ return_value=threads.deferToThread(MagicMock(
+ side_effect=Exception())))
+ auth._gui_notify = mock.create_autospec(
+ auth._gui_notify)
+ auth._errback = mock.create_autospec(
+ auth._errback)
+
+ d = auth.authenticate("", "")
+
+ def check(*args):
+ self.assertFalse(auth._gui_notify.called)
+ self.assertEqual(auth._errback.call_count, 1)
+
+ d.addCallback(check)
+ return d
+
+ @deferred()
+ def test_authenticate_runs_cleanly_when_raises(self):
+ auth = srpauth.SRPAuth(ProviderConfig())
+ auth._SRPAuth__instance.authenticate = mock.create_autospec(
+ auth._SRPAuth__instance.authenticate,
+ return_value=threads.deferToThread(MagicMock(
+ side_effect=Exception())))
+
+ d = auth.authenticate("", "")
+
+ return d
+
+ @deferred()
+ def test_authenticate_runs_cleanly(self):
+ auth = srpauth.SRPAuth(ProviderConfig())
+ auth._SRPAuth__instance.authenticate = mock.create_autospec(
+ auth._SRPAuth__instance.authenticate,
+ return_value=threads.deferToThread(MagicMock()))
+
+ d = auth.authenticate("", "")
+
+ return d
+
+ def test_logout(self):
+ auth = srpauth.SRPAuth(ProviderConfig())
+ auth._SRPAuth__instance.logout = mock.create_autospec(
+ auth._SRPAuth__instance.logout)
+
+ self.assertTrue(auth.logout())
+
+ def test_logout_rets_false_when_raises(self):
+ auth = srpauth.SRPAuth(ProviderConfig())
+ auth._SRPAuth__instance.logout = mock.create_autospec(
+ auth._SRPAuth__instance.logout,
+ side_effect=Exception())
+
+ self.assertFalse(auth.logout())
diff --git a/src/leap/bitmask/crypto/tests/test_srpregister.py b/src/leap/bitmask/crypto/tests/test_srpregister.py
new file mode 100644
index 00000000..4d6e7be3
--- /dev/null
+++ b/src/leap/bitmask/crypto/tests/test_srpregister.py
@@ -0,0 +1,201 @@
+# -*- coding: utf-8 -*-
+# test_srpregister.py
+# Copyright (C) 2013 LEAP
+#
+# 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/crypto/srpregister.py
+"""
+try:
+ import unittest2 as unittest
+except ImportError:
+ import unittest
+import os
+import sys
+
+from mock import MagicMock
+from nose.twistedtools import reactor, deferred
+from twisted.python import log
+from twisted.internet import threads
+
+from leap.bitmask.config.providerconfig import ProviderConfig
+from leap.bitmask.crypto import srpregister, srpauth
+from leap.bitmask.crypto.tests import fake_provider
+from leap.common.testing.https_server import where
+
+log.startLogging(sys.stdout)
+
+
+def _get_capath():
+ return where("cacert.pem")
+
+_here = os.path.split(__file__)[0]
+
+
+class ImproperlyConfiguredError(Exception):
+ """
+ Raised if the test provider is missing configuration
+ """
+
+
+class SRPTestCase(unittest.TestCase):
+ """
+ Tests for the SRPRegister class
+ """
+ __name__ = "SRPRegister tests"
+
+ @classmethod
+ def setUpClass(cls):
+ """
+ Sets up this TestCase with a simple and faked provider instance:
+
+ * runs a threaded reactor
+ * loads a mocked ProviderConfig that points to the certs in the
+ leap.common.testing module.
+ """
+ factory = fake_provider.get_provider_factory()
+ http = reactor.listenTCP(8001, factory)
+ https = reactor.listenSSL(
+ 0, factory,
+ fake_provider.OpenSSLServerContextFactory())
+ get_port = lambda p: p.getHost().port
+ cls.http_port = get_port(http)
+ cls.https_port = get_port(https)
+
+ provider = ProviderConfig()
+ provider.get_ca_cert_path = MagicMock()
+ provider.get_ca_cert_path.return_value = _get_capath()
+
+ provider.get_api_uri = MagicMock()
+ provider.get_api_uri.return_value = cls._get_https_uri()
+
+ loaded = provider.load(path=os.path.join(
+ _here, "test_provider.json"))
+ if not loaded:
+ raise ImproperlyConfiguredError(
+ "Could not load test provider config")
+ cls.register = srpregister.SRPRegister(provider_config=provider)
+
+ cls.auth = srpauth.SRPAuth(provider)
+
+ # helper methods
+
+ @classmethod
+ def _get_https_uri(cls):
+ """
+ Returns a https uri with the right https port initialized
+ """
+ return "https://localhost:%s" % (cls.https_port,)
+
+ # Register tests
+
+ def test_none_port(self):
+ provider = ProviderConfig()
+ provider.get_api_uri = MagicMock()
+ provider.get_api_uri.return_value = "http://localhost/"
+ loaded = provider.load(path=os.path.join(
+ _here, "test_provider.json"))
+ if not loaded:
+ raise ImproperlyConfiguredError(
+ "Could not load test provider config")
+
+ register = srpregister.SRPRegister(provider_config=provider)
+ self.assertEquals(register._port, "443")
+
+ @deferred()
+ def test_wrong_cert(self):
+ provider = ProviderConfig()
+ loaded = provider.load(path=os.path.join(
+ _here, "test_provider.json"))
+ provider.get_ca_cert_path = MagicMock()
+ provider.get_ca_cert_path.return_value = os.path.join(
+ _here,
+ "wrongcert.pem")
+ provider.get_api_uri = MagicMock()
+ provider.get_api_uri.return_value = self._get_https_uri()
+ if not loaded:
+ raise ImproperlyConfiguredError(
+ "Could not load test provider config")
+
+ register = srpregister.SRPRegister(provider_config=provider)
+ d = threads.deferToThread(register.register_user, "foouser_firsttime",
+ "barpass")
+ d.addCallback(self.assertFalse)
+ return d
+
+ @deferred()
+ def test_register_user(self):
+ """
+ Checks if the registration of an unused name works as expected when
+ it is the first time that we attempt to register that user, as well as
+ when we request a user that is taken.
+ """
+ # pristine registration
+ d = threads.deferToThread(self.register.register_user,
+ "foouser_firsttime",
+ "barpass")
+ d.addCallback(self.assertTrue)
+ return d
+
+ @deferred()
+ def test_second_register_user(self):
+ # second registration attempt with the same user should return errors
+ d = threads.deferToThread(self.register.register_user,
+ "foouser_second",
+ "barpass")
+ d.addCallback(self.assertTrue)
+
+ # FIXME currently we are catching this in an upper layer,
+ # we could bring the error validation to the SRPRegister class
+ def register_wrapper(_):
+ return threads.deferToThread(self.register.register_user,
+ "foouser_second",
+ "barpass")
+ d.addCallback(register_wrapper)
+ d.addCallback(self.assertFalse)
+ return d
+
+ @deferred()
+ def test_correct_http_uri(self):
+ """
+ Checks that registration autocorrect http uris to https ones.
+ """
+ HTTP_URI = "http://localhost:%s" % (self.https_port, )
+ HTTPS_URI = "https://localhost:%s/1/users" % (self.https_port, )
+ provider = ProviderConfig()
+ provider.get_ca_cert_path = MagicMock()
+ provider.get_ca_cert_path.return_value = _get_capath()
+ provider.get_api_uri = MagicMock()
+
+ # we introduce a http uri in the config file...
+ provider.get_api_uri.return_value = HTTP_URI
+ loaded = provider.load(path=os.path.join(
+ _here, "test_provider.json"))
+ if not loaded:
+ raise ImproperlyConfiguredError(
+ "Could not load test provider config")
+
+ register = srpregister.SRPRegister(provider_config=provider)
+
+ # ... and we check that we're correctly taking the HTTPS protocol
+ # instead
+ reg_uri = register._get_registration_uri()
+ self.assertEquals(reg_uri, HTTPS_URI)
+ register._get_registration_uri = MagicMock(return_value=HTTPS_URI)
+ d = threads.deferToThread(register.register_user, "test_failhttp",
+ "barpass")
+ d.addCallback(self.assertTrue)
+
+ return d
diff --git a/src/leap/bitmask/crypto/tests/wrongcert.pem b/src/leap/bitmask/crypto/tests/wrongcert.pem
new file mode 100644
index 00000000..e6cff38a
--- /dev/null
+++ b/src/leap/bitmask/crypto/tests/wrongcert.pem
@@ -0,0 +1,33 @@
+-----BEGIN CERTIFICATE-----
+MIIFtTCCA52gAwIBAgIJAIWZus5EIXNtMA0GCSqGSIb3DQEBBQUAMEUxCzAJBgNV
+BAYTAkFVMRMwEQYDVQQIEwpTb21lLVN0YXRlMSEwHwYDVQQKExhJbnRlcm5ldCBX
+aWRnaXRzIFB0eSBMdGQwHhcNMTMwNjI1MTc0NjExWhcNMTgwNjI1MTc0NjExWjBF
+MQswCQYDVQQGEwJBVTETMBEGA1UECBMKU29tZS1TdGF0ZTEhMB8GA1UEChMYSW50
+ZXJuZXQgV2lkZ2l0cyBQdHkgTHRkMIICIjANBgkqhkiG9w0BAQEFAAOCAg8AMIIC
+CgKCAgEA2ObM7ESjyuxFZYD/Y68qOPQgjgggW+cdXfBpU2p4n7clsrUeMhWdW40Y
+77Phzor9VOeqs3ZpHuyLzsYVp/kFDm8tKyo2ah5fJwzL0VCSLYaZkUQQ7GNUmTCk
+furaxl8cQx/fg395V7/EngsS9B3/y5iHbctbA4MnH3jaotO5EGeo6hw7/eyCotQ9
+KbBV9GJMcY94FsXBCmUB+XypKklWTLhSaS6Cu4Fo8YLW6WmcnsyEOGS2F7WVf5at
+7CBWFQZHaSgIBLmc818/mDYCnYmCVMFn/6Ndx7V2NTlz+HctWrQn0dmIOnCUeCwS
+wXq9PnBR1rSx/WxwyF/WpyjOFkcIo7vm72kS70pfrYsXcZD4BQqkXYj3FyKnPt3O
+ibLKtCxL8/83wOtErPcYpG6LgFkgAAlHQ9MkUi5dbmjCJtpqQmlZeK1RALdDPiB3
+K1KZimrGsmcE624dJxUIOJJpuwJDy21F8kh5ZAsAtE1prWETrQYNElNFjQxM83rS
+ZR1Ql2MPSB4usEZT57+KvpEzlOnAT3elgCg21XrjSFGi14hCEao4g2OEZH5GAwm5
+frf6UlSRZ/g3tLTfI8Hv1prw15W2qO+7q7SBAplTODCRk+Yb0YoA2mMM/QXBUcXs
+vKEDLSSxzNIBi3T62l39RB/ml+gPKo87ZMDivex1ZhrcJc3Yu3sCAwEAAaOBpzCB
+pDAdBgNVHQ4EFgQUPjE+4pun+8FreIdpoR8v6N7xKtUwdQYDVR0jBG4wbIAUPjE+
+4pun+8FreIdpoR8v6N7xKtWhSaRHMEUxCzAJBgNVBAYTAkFVMRMwEQYDVQQIEwpT
+b21lLVN0YXRlMSEwHwYDVQQKExhJbnRlcm5ldCBXaWRnaXRzIFB0eSBMdGSCCQCF
+mbrORCFzbTAMBgNVHRMEBTADAQH/MA0GCSqGSIb3DQEBBQUAA4ICAQCpvCPdtvXJ
+muTj379TZuCJs7/l0FhA7AHa1WAlHjsXHaA7N0+3ZWAbdtXDsowal6S+ldgU/kfV
+Lq7NrRq+amJWC7SYj6cvVwhrSwSvu01fe/TWuOzHrRv1uTfJ/VXLonVufMDd9opo
+bhqYxMaxLdIx6t/MYmZH4Wpiq0yfZuv//M8i7BBl/qvaWbLhg0yVAKRwjFvf59h6
+6tRFCLddELOIhLDQtk8zMbioPEbfAlKdwwP8kYGtDGj6/9/YTd/oTKRdgHuwyup3
+m0L20Y6LddC+tb0WpK5EyrNbCbEqj1L4/U7r6f/FKNA3bx6nfdXbscaMfYonKAKg
+1cRrRg45sErmCz0QyTnWzXyvbjR4oQRzyW3kJ1JZudZ+AwOi00J5FYa3NiLuxl1u
+gIGKWSrASQWhEdpa1nlCgX7PhdaQgYjEMpQvA0GCA0OF5JDu8en1yZqsOt1hCLIN
+lkz/5jKPqrclY5hV99bE3hgCHRmIPNHCZG3wbZv2yJKxJX1YLMmQwAmSh2N7YwGG
+yXRvCxQs5ChPHyRairuf/5MZCZnSVb45ppTVuNUijsbflKRUgfj/XvfqQ22f+C9N
+Om2dmNvAiS2TOIfuP47CF2OUa5q4plUwmr+nyXQGM0SIoHNCj+MBdFfb3oxxAtI+
+SLhbnzQv5e84Doqz3YF0XW8jyR7q8GFLNA==
+-----END CERTIFICATE-----
diff --git a/src/leap/bitmask/gui/__init__.py b/src/leap/bitmask/gui/__init__.py
new file mode 100644
index 00000000..4b289442
--- /dev/null
+++ b/src/leap/bitmask/gui/__init__.py
@@ -0,0 +1,21 @@
+# -*- coding: utf-8 -*-
+# __init__.py
+# Copyright (C) 2013 LEAP
+#
+# 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/>.
+"""
+init file for leap.gui
+"""
+app = __import__("app", globals(), locals(), [], 2)
+__all__ = [app]
diff --git a/src/leap/bitmask/gui/loggerwindow.py b/src/leap/bitmask/gui/loggerwindow.py
new file mode 100644
index 00000000..981bf65d
--- /dev/null
+++ b/src/leap/bitmask/gui/loggerwindow.py
@@ -0,0 +1,139 @@
+# -*- coding: utf-8 -*-
+# loggerwindow.py
+# Copyright (C) 2013 LEAP
+#
+# 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/>.
+
+"""
+History log window
+"""
+import logging
+
+from PySide import QtGui
+
+from ui_loggerwindow import Ui_LoggerWindow
+
+from leap.bitmask.util.leap_log_handler import LeapLogHandler
+from leap.common.check import leap_assert, leap_assert_type
+
+logger = logging.getLogger(__name__)
+
+
+class LoggerWindow(QtGui.QDialog):
+ """
+ Window that displays a history of the logged messages in the app.
+ """
+ def __init__(self, handler):
+ """
+ Initialize the widget with the custom handler.
+
+ :param handler: Custom handler that supports history and signal.
+ :type handler: LeapLogHandler.
+ """
+ QtGui.QDialog.__init__(self)
+ leap_assert(handler, "We need a handler for the logger window")
+ leap_assert_type(handler, LeapLogHandler)
+
+ # Load UI
+ self.ui = Ui_LoggerWindow()
+ self.ui.setupUi(self)
+
+ # Make connections
+ self.ui.btnSave.clicked.connect(self._save_log_to_file)
+ self.ui.btnDebug.toggled.connect(self._load_history),
+ self.ui.btnInfo.toggled.connect(self._load_history),
+ self.ui.btnWarning.toggled.connect(self._load_history),
+ self.ui.btnError.toggled.connect(self._load_history),
+ self.ui.btnCritical.toggled.connect(self._load_history)
+
+ # Load logging history and connect logger with the widget
+ self._logging_handler = handler
+ self._connect_to_handler()
+ self._load_history()
+
+ def _connect_to_handler(self):
+ """
+ This method connects the loggerwindow with the handler through a
+ signal communicate the logger events.
+ """
+ self._logging_handler.new_log.connect(self._add_log_line)
+
+ def _add_log_line(self, log):
+ """
+ Adds a line to the history, only if it's in the desired levels to show.
+
+ :param log: a log record to be inserted in the widget
+ :type log: a dict with RECORD_KEY and MESSAGE_KEY.
+ the record contains the LogRecord of the logging module,
+ the message contains the formatted message for the log.
+ """
+ html_style = {
+ logging.DEBUG: "background: #CDFFFF;",
+ logging.INFO: "background: white;",
+ logging.WARNING: "background: #FFFF66;",
+ logging.ERROR: "background: red; color: white;",
+ logging.CRITICAL: "background: red; color: white; font: bold;"
+ }
+ level = log[LeapLogHandler.RECORD_KEY].levelno
+ message = log[LeapLogHandler.MESSAGE_KEY]
+ message = message.replace('\n', '<br>\n')
+
+ if self._logs_to_display[level]:
+ open_tag = "<tr style='" + html_style[level] + "'>"
+ open_tag += "<td width='100%' style='padding: 5px;'>"
+ close_tag = "</td></tr>"
+ message = open_tag + message + close_tag
+
+ self.ui.txtLogHistory.append(message)
+
+ def _load_history(self):
+ """
+ Load the previous logged messages in the widget.
+ They are stored in the custom handler.
+ """
+ self._set_logs_to_display()
+ self.ui.txtLogHistory.clear()
+ history = self._logging_handler.log_history
+ for line in history:
+ self._add_log_line(line)
+
+ def _set_logs_to_display(self):
+ """
+ Sets the logs_to_display dict getting the toggled options from the ui
+ """
+ self._logs_to_display = {
+ logging.DEBUG: self.ui.btnDebug.isChecked(),
+ logging.INFO: self.ui.btnInfo.isChecked(),
+ logging.WARNING: self.ui.btnWarning.isChecked(),
+ logging.ERROR: self.ui.btnError.isChecked(),
+ logging.CRITICAL: self.ui.btnCritical.isChecked()
+ }
+
+ def _save_log_to_file(self):
+ """
+ Lets the user save the current log to a file
+ """
+ fileName, filtr = QtGui.QFileDialog.getSaveFileName(
+ self, self.tr("Save As"))
+
+ if fileName:
+ try:
+ with open(fileName, 'w') as output:
+ output.write(self.ui.txtLogHistory.toPlainText())
+ output.write('\n')
+ logger.debug('Log saved in %s' % (fileName, ))
+ except IOError, e:
+ logger.error("Error saving log file: %r" % (e, ))
+ else:
+ logger.debug('Log not saved!')
diff --git a/src/leap/bitmask/gui/login.py b/src/leap/bitmask/gui/login.py
new file mode 100644
index 00000000..db7b8e2a
--- /dev/null
+++ b/src/leap/bitmask/gui/login.py
@@ -0,0 +1,245 @@
+# -*- coding: utf-8 -*-
+# login.py
+# Copyright (C) 2013 LEAP
+#
+# 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/>.
+
+"""
+Login widget implementation
+"""
+import logging
+
+from PySide import QtCore, QtGui
+from ui_login import Ui_LoginWidget
+
+from leap.bitmask.util.keyring_helpers import has_keyring
+
+logger = logging.getLogger(__name__)
+
+
+class LoginWidget(QtGui.QWidget):
+ """
+ Login widget that emits signals to display the wizard or to
+ perform login.
+ """
+
+ # Emitted when the login button is clicked
+ login = QtCore.Signal()
+ cancel_login = QtCore.Signal()
+
+ # Emitted when the user selects "Other..." in the provider
+ # combobox or click "Create Account"
+ show_wizard = QtCore.Signal()
+
+ MAX_STATUS_WIDTH = 40
+
+ BARE_USERNAME_REGEX = r"^[A-Za-z\d_]+$"
+
+ def __init__(self, settings, parent=None):
+ """
+ Constructs the LoginWidget.
+
+ :param settings: client wide settings
+ :type settings: LeapSettings
+ :param parent: The parent widget for this widget
+ :type parent: QWidget or None
+ """
+ QtGui.QWidget.__init__(self, parent)
+
+ self._settings = settings
+ self._selected_provider_index = -1
+
+ self.ui = Ui_LoginWidget()
+ self.ui.setupUi(self)
+
+ self.ui.chkRemember.stateChanged.connect(
+ self._remember_state_changed)
+ self.ui.chkRemember.setEnabled(has_keyring())
+
+ self.ui.lnPassword.setEchoMode(QtGui.QLineEdit.Password)
+
+ self.ui.btnLogin.clicked.connect(self.login)
+ self.ui.lnPassword.returnPressed.connect(self.login)
+
+ self.ui.lnUser.returnPressed.connect(self._focus_password)
+
+ self.ui.cmbProviders.currentIndexChanged.connect(
+ self._current_provider_changed)
+ self.ui.btnCreateAccount.clicked.connect(
+ self.show_wizard)
+
+ username_re = QtCore.QRegExp(self.BARE_USERNAME_REGEX)
+ self.ui.lnUser.setValidator(
+ QtGui.QRegExpValidator(username_re, self))
+
+ def _remember_state_changed(self, state):
+ """
+ Saves the remember state in the LeapSettings
+
+ :param state: possible stats can be Checked, Unchecked and
+ PartiallyChecked
+ :type state: QtCore.Qt.CheckState
+ """
+ enable = True if state == QtCore.Qt.Checked else False
+ self._settings.set_remember(enable)
+
+ def set_providers(self, provider_list):
+ """
+ Set the provider list to provider_list plus an "Other..." item
+ that triggers the wizard
+
+ :param provider_list: list of providers
+ :type provider_list: list of str
+ """
+ self.ui.cmbProviders.blockSignals(True)
+ self.ui.cmbProviders.clear()
+ self.ui.cmbProviders.addItems(provider_list + [self.tr("Other...")])
+ self.ui.cmbProviders.blockSignals(False)
+
+ def select_provider_by_name(self, name):
+ """
+ Given a provider name/domain, it selects it in the combobox
+
+ :param name: name or domain for the provider
+ :type name: str
+ """
+ provider_index = self.ui.cmbProviders.findText(name)
+ self.ui.cmbProviders.setCurrentIndex(provider_index)
+
+ def get_selected_provider(self):
+ """
+ Returns the selected provider in the combobox
+ """
+ return self.ui.cmbProviders.currentText()
+
+ def set_remember(self, value):
+ """
+ Checks the remember user and password checkbox
+
+ :param value: True to mark it checked, False otherwise
+ :type value: bool
+ """
+ self.ui.chkRemember.setChecked(value)
+
+ def get_remember(self):
+ """
+ Returns the remember checkbox state
+
+ :rtype: bool
+ """
+ return self.ui.chkRemember.isChecked()
+
+ def set_user(self, user):
+ """
+ Sets the user and focuses on the next field, password.
+
+ :param user: user to set the field to
+ :type user: str
+ """
+ self.ui.lnUser.setText(user)
+ self._focus_password()
+
+ def get_user(self):
+ """
+ Returns the user that appears in the widget.
+
+ :rtype: str
+ """
+ return self.ui.lnUser.text()
+
+ def set_password(self, password):
+ """
+ Sets the password for the widget
+
+ :param password: password to set
+ :type password: str
+ """
+ self.ui.lnPassword.setText(password)
+
+ def get_password(self):
+ """
+ Returns the password that appears in the widget
+
+ :rtype: str
+ """
+ return self.ui.lnPassword.text()
+
+ def set_status(self, status, error=True):
+ """
+ Sets the status label at the login stage to status
+
+ :param status: status message
+ :type status: str
+ """
+ if len(status) > self.MAX_STATUS_WIDTH:
+ status = status[:self.MAX_STATUS_WIDTH] + "..."
+ if error:
+ status = "<font color='red'><b>%s</b></font>" % (status,)
+ self.ui.lblStatus.setText(status)
+
+ def set_enabled(self, enabled=False):
+ """
+ Enables or disables all the login widgets
+
+ :param enabled: wether they should be enabled or not
+ :type enabled: bool
+ """
+ self.ui.lnUser.setEnabled(enabled)
+ self.ui.lnPassword.setEnabled(enabled)
+ self.ui.chkRemember.setEnabled(enabled)
+ self.ui.cmbProviders.setEnabled(enabled)
+
+ self._set_cancel(not enabled)
+
+ def _set_cancel(self, enabled=False):
+ """
+ Enables or disables the cancel action in the "log in" process.
+
+ :param enabled: wether it should be enabled or not
+ :type enabled: bool
+ """
+ text = self.tr("Cancel")
+ login_or_cancel = self.cancel_login
+
+ if not enabled:
+ text = self.tr("Log In")
+ login_or_cancel = self.login
+
+ self.ui.btnLogin.setText(text)
+
+ self.ui.btnLogin.clicked.disconnect()
+ self.ui.btnLogin.clicked.connect(login_or_cancel)
+
+ def _focus_password(self):
+ """
+ Focuses in the password lineedit
+ """
+ self.ui.lnPassword.setFocus()
+
+ def _current_provider_changed(self, param):
+ """
+ SLOT
+ TRIGGERS: self.ui.cmbProviders.currentIndexChanged
+ """
+ if param == (self.ui.cmbProviders.count() - 1):
+ self.show_wizard.emit()
+ # Leave the previously selected provider in the combobox
+ prev_provider = 0
+ if self._selected_provider_index != -1:
+ prev_provider = self._selected_provider_index
+ self.ui.cmbProviders.blockSignals(True)
+ self.ui.cmbProviders.setCurrentIndex(prev_provider)
+ self.ui.cmbProviders.blockSignals(False)
+ else:
+ self._selected_provider_index = param
diff --git a/src/leap/bitmask/gui/mainwindow.py b/src/leap/bitmask/gui/mainwindow.py
new file mode 100644
index 00000000..6dd28f04
--- /dev/null
+++ b/src/leap/bitmask/gui/mainwindow.py
@@ -0,0 +1,1542 @@
+# -*- coding: utf-8 -*-
+# mainwindow.py
+# Copyright (C) 2013 LEAP
+#
+# 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/>.
+
+"""
+Main window for the leap client
+"""
+import logging
+import os
+import platform
+import tempfile
+from functools import partial
+
+import keyring
+
+from PySide import QtCore, QtGui
+from twisted.internet import threads
+
+from leap.bitmask.config.leapsettings import LeapSettings
+from leap.bitmask.config.providerconfig import ProviderConfig
+from leap.bitmask.crypto.srpauth import SRPAuth
+from leap.bitmask.gui.loggerwindow import LoggerWindow
+from leap.bitmask.gui.wizard import Wizard
+from leap.bitmask.gui.login import LoginWidget
+from leap.bitmask.gui.statuspanel import StatusPanelWidget
+from leap.bitmask.services.eip.eipbootstrapper import EIPBootstrapper
+from leap.bitmask.services.eip.eipconfig import EIPConfig
+from leap.bitmask.services.eip.providerbootstrapper import ProviderBootstrapper
+# XXX: Soledad might not work out of the box in Windows, issue #2932
+from leap.bitmask.services.soledad.soledadbootstrapper import \
+ SoledadBootstrapper
+from leap.bitmask.services.mail.smtpbootstrapper import SMTPBootstrapper
+from leap.bitmask.services.mail import imap
+from leap.bitmask.platform_init import IS_WIN, IS_MAC
+from leap.bitmask.platform_init.initializers import init_platform
+
+from leap.bitmask.services.eip.vpnprocess import VPN
+from leap.bitmask.services.eip.vpnprocess import OpenVPNAlreadyRunning
+from leap.bitmask.services.eip.vpnprocess import AlienOpenVPNAlreadyRunning
+
+from leap.bitmask.services.eip.vpnlaunchers import VPNLauncherException
+from leap.bitmask.services.eip.vpnlaunchers import OpenVPNNotFoundException
+from leap.bitmask.services.eip.vpnlaunchers import EIPNoPkexecAvailable
+from leap.bitmask.services.eip.vpnlaunchers import \
+ EIPNoPolkitAuthAgentAvailable
+from leap.bitmask.services.eip.vpnlaunchers import EIPNoTunKextLoaded
+
+from leap.bitmask.util import __version__ as VERSION
+from leap.bitmask.util.keyring_helpers import has_keyring
+
+from leap.bitmask.services.mail.smtpconfig import SMTPConfig
+
+if IS_WIN:
+ from leap.bitmask.platform_init.locks import WindowsLock
+ from leap.bitmask.platform_init.locks import raise_window_ack
+
+from leap.common.check import leap_assert
+from leap.common.events import register
+from leap.common.events import events_pb2 as proto
+
+from ui_mainwindow import Ui_MainWindow
+
+logger = logging.getLogger(__name__)
+
+
+class MainWindow(QtGui.QMainWindow):
+ """
+ Main window for login and presenting status updates to the user
+ """
+
+ # StackedWidget indexes
+ LOGIN_INDEX = 0
+ EIP_STATUS_INDEX = 1
+
+ # Keyring
+ KEYRING_KEY = "bitmask"
+
+ # SMTP
+ PORT_KEY = "port"
+ IP_KEY = "ip_address"
+
+ OPENVPN_SERVICE = "openvpn"
+ MX_SERVICE = "mx"
+
+ # Signals
+ new_updates = QtCore.Signal(object)
+ raise_window = QtCore.Signal([])
+ soledad_ready = QtCore.Signal([])
+
+ # We use this flag to detect abnormal terminations
+ user_stopped_eip = False
+
+ def __init__(self, quit_callback,
+ standalone=False,
+ openvpn_verb=1,
+ bypass_checks=False):
+ """
+ Constructor for the client main window
+
+ :param quit_callback: Function to be called when closing
+ the application.
+ :type quit_callback: callable
+
+ :param standalone: Set to true if the app should use configs
+ inside its pwd
+ :type standalone: bool
+
+ :param bypass_checks: Set to true if the app should bypass
+ first round of checks for CA
+ certificates at bootstrap
+ :type bypass_checks: bool
+ """
+ QtGui.QMainWindow.__init__(self)
+
+ # register leap events
+ register(signal=proto.UPDATER_NEW_UPDATES,
+ callback=self._new_updates_available,
+ reqcbk=lambda req, resp: None) # make rpc call async
+ register(signal=proto.RAISE_WINDOW,
+ callback=self._on_raise_window_event,
+ reqcbk=lambda req, resp: None) # make rpc call async
+
+ self._quit_callback = quit_callback
+
+ self._updates_content = ""
+
+ self.ui = Ui_MainWindow()
+ self.ui.setupUi(self)
+
+ self._settings = LeapSettings(standalone)
+
+ self._login_widget = LoginWidget(
+ self._settings,
+ self.ui.stackedWidget.widget(self.LOGIN_INDEX))
+ self.ui.loginLayout.addWidget(self._login_widget)
+
+ # Signals
+ # TODO separate logic from ui signals.
+
+ self._login_widget.login.connect(self._login)
+ self._login_widget.cancel_login.connect(self._cancel_login)
+ self._login_widget.show_wizard.connect(
+ self._launch_wizard)
+
+ self.ui.btnShowLog.clicked.connect(self._show_logger_window)
+
+ self._status_panel = StatusPanelWidget(
+ self.ui.stackedWidget.widget(self.EIP_STATUS_INDEX))
+ self.ui.statusLayout.addWidget(self._status_panel)
+
+ self.ui.stackedWidget.setCurrentIndex(self.LOGIN_INDEX)
+
+ self._status_panel.start_eip.connect(self._start_eip)
+ self._status_panel.stop_eip.connect(self._stop_eip)
+
+ # This is loaded only once, there's a bug when doing that more
+ # than once
+ ProviderConfig.standalone = standalone
+ EIPConfig.standalone = standalone
+ self._standalone = standalone
+ self._provider_config = ProviderConfig()
+ # Used for automatic start of EIP
+ self._provisional_provider_config = ProviderConfig()
+ self._eip_config = EIPConfig()
+
+ self._already_started_eip = False
+
+ # This is created once we have a valid provider config
+ self._srp_auth = None
+ self._logged_user = None
+
+ # This thread is always running, although it's quite
+ # lightweight when it's done setting up provider
+ # configuration and certificate.
+ self._provider_bootstrapper = ProviderBootstrapper(bypass_checks)
+
+ # Intermediate stages, only do something if there was an error
+ self._provider_bootstrapper.name_resolution.connect(
+ self._intermediate_stage)
+ self._provider_bootstrapper.https_connection.connect(
+ self._intermediate_stage)
+ self._provider_bootstrapper.download_ca_cert.connect(
+ self._intermediate_stage)
+
+ # Important stages, loads the provider config and checks
+ # certificates
+ self._provider_bootstrapper.download_provider_info.connect(
+ self._load_provider_config)
+ self._provider_bootstrapper.check_api_certificate.connect(
+ self._provider_config_loaded)
+
+ # This thread is similar to the provider bootstrapper
+ self._eip_bootstrapper = EIPBootstrapper()
+
+ self._eip_bootstrapper.download_config.connect(
+ self._eip_intermediate_stage)
+ self._eip_bootstrapper.download_client_certificate.connect(
+ self._finish_eip_bootstrap)
+
+ self._soledad_bootstrapper = SoledadBootstrapper()
+ self._soledad_bootstrapper.download_config.connect(
+ self._soledad_intermediate_stage)
+ self._soledad_bootstrapper.gen_key.connect(
+ self._soledad_bootstrapped_stage)
+
+ self._smtp_bootstrapper = SMTPBootstrapper()
+ self._smtp_bootstrapper.download_config.connect(
+ self._smtp_bootstrapped_stage)
+
+ self._vpn = VPN(openvpn_verb=openvpn_verb)
+ self._vpn.qtsigs.state_changed.connect(
+ self._status_panel.update_vpn_state)
+ self._vpn.qtsigs.status_changed.connect(
+ self._status_panel.update_vpn_status)
+ self._vpn.qtsigs.process_finished.connect(
+ self._eip_finished)
+
+ self.ui.action_log_out.setEnabled(False)
+ self.ui.action_log_out.triggered.connect(self._logout)
+ self.ui.action_about_leap.triggered.connect(self._about)
+ self.ui.action_quit.triggered.connect(self.quit)
+ self.ui.action_wizard.triggered.connect(self._launch_wizard)
+ self.ui.action_show_logs.triggered.connect(self._show_logger_window)
+ self.raise_window.connect(self._do_raise_mainwindow)
+
+ # Used to differentiate between real quits and close to tray
+ self._really_quit = False
+
+ self._systray = None
+
+ self._action_eip_provider = QtGui.QAction(
+ self.tr("No default provider"), self)
+ self._action_eip_provider.setEnabled(False)
+ self._action_eip_status = QtGui.QAction(
+ self.tr("Encrypted internet is OFF"),
+ self)
+ self._action_eip_status.setEnabled(False)
+
+ self._status_panel.set_action_eip_status(
+ self._action_eip_status)
+
+ self._action_eip_startstop = QtGui.QAction(
+ self.tr("Turn OFF"), self)
+ self._action_eip_startstop.triggered.connect(
+ self._stop_eip)
+ self._action_eip_startstop.setEnabled(False)
+ self._status_panel.set_action_eip_startstop(
+ self._action_eip_startstop)
+
+ self._action_visible = QtGui.QAction(self.tr("Hide Main Window"), self)
+ self._action_visible.triggered.connect(self._toggle_visible)
+
+ self._enabled_services = []
+
+ self._center_window()
+
+ self.ui.lblNewUpdates.setVisible(False)
+ self.ui.btnMore.setVisible(False)
+ self.ui.btnMore.clicked.connect(self._updates_details)
+
+ self.new_updates.connect(self._react_to_new_updates)
+ self.soledad_ready.connect(self._start_imap_service)
+
+ init_platform()
+
+ self._wizard = None
+ self._wizard_firstrun = False
+
+ self._logger_window = None
+
+ self._bypass_checks = bypass_checks
+
+ self._soledad = None
+ self._keymanager = None
+ self._imap_service = None
+
+ self._login_defer = None
+ self._download_provider_defer = None
+
+ self._smtp_config = SMTPConfig()
+
+ if self._first_run():
+ self._wizard_firstrun = True
+ self._wizard = Wizard(standalone=standalone,
+ bypass_checks=bypass_checks)
+ # Give this window time to finish init and then show the wizard
+ QtCore.QTimer.singleShot(1, self._launch_wizard)
+ self._wizard.accepted.connect(self._finish_init)
+ self._wizard.rejected.connect(self._rejected_wizard)
+ else:
+ self._finish_init()
+
+ def _rejected_wizard(self):
+ """
+ SLOT
+ TRIGGERS: self._wizard.rejected
+
+ Called if the wizard has been cancelled or closed before
+ finishing.
+ """
+ if self._wizard_firstrun:
+ self._settings.set_properprovider(False)
+ self.quit()
+ else:
+ self._finish_init()
+
+ def _launch_wizard(self):
+ """
+ SLOT
+ TRIGGERS:
+ self._login_widget.show_wizard
+ self.ui.action_wizard.triggered
+
+ Also called in first run.
+
+ Launches the wizard, creating the object itself if not already
+ there.
+ """
+ if self._wizard is None:
+ self._wizard = Wizard(bypass_checks=self._bypass_checks)
+ self._wizard.accepted.connect(self._finish_init)
+ self._wizard.rejected.connect(self._wizard.close)
+
+ self.setVisible(False)
+ # Do NOT use exec_, it will use a child event loop!
+ # Refer to http://www.themacaque.com/?p=1067 for funny details.
+ self._wizard.show()
+ if IS_MAC:
+ self._wizard.raise_()
+ self._wizard.finished.connect(self._wizard_finished)
+
+ def _wizard_finished(self):
+ """
+ SLOT
+ TRIGGERS
+ self._wizard.finished
+
+ Called when the wizard has finished.
+ """
+ self.setVisible(True)
+
+ def _get_leap_logging_handler(self):
+ """
+ Gets the leap handler from the top level logger
+
+ :return: a logging handler or None
+ :rtype: LeapLogHandler or None
+ """
+ from leap.util.leap_log_handler import LeapLogHandler
+ leap_logger = logging.getLogger('leap')
+ for h in leap_logger.handlers:
+ if isinstance(h, LeapLogHandler):
+ return h
+ return None
+
+ def _show_logger_window(self):
+ """
+ SLOT
+ TRIGGERS:
+ self.ui.action_show_logs.triggered
+ self.ui.btnShowLog.clicked
+
+ Displays the window with the history of messages logged until now
+ and displays the new ones on arrival.
+ """
+ if self._logger_window is None:
+ leap_log_handler = self._get_leap_logging_handler()
+ if leap_log_handler is None:
+ logger.error('Leap logger handler not found')
+ else:
+ self._logger_window = LoggerWindow(handler=leap_log_handler)
+ self._logger_window.setVisible(
+ not self._logger_window.isVisible())
+ self.ui.btnShowLog.setChecked(self._logger_window.isVisible())
+ else:
+ self._logger_window.setVisible(not self._logger_window.isVisible())
+ self.ui.btnShowLog.setChecked(self._logger_window.isVisible())
+
+ self._logger_window.finished.connect(self._uncheck_logger_button)
+
+ def _uncheck_logger_button(self):
+ """
+ SLOT
+ Sets the checked state of the loggerwindow button to false.
+ """
+ self.ui.btnShowLog.setChecked(False)
+
+ def _new_updates_available(self, req):
+ """
+ Callback for the new updates event
+
+ :param req: Request type
+ :type req: leap.common.events.events_pb2.SignalRequest
+ """
+ self.new_updates.emit(req)
+
+ def _react_to_new_updates(self, req):
+ """
+ SLOT
+ TRIGGER: self._new_updates_available
+
+ Displays the new updates label and sets the updates_content
+ """
+ self.moveToThread(QtCore.QCoreApplication.instance().thread())
+ self.ui.lblNewUpdates.setVisible(True)
+ self.ui.btnMore.setVisible(True)
+ self._updates_content = req.content
+
+ def _updates_details(self):
+ """
+ SLOT
+ TRIGGER: self.ui.btnMore.clicked
+
+ Parses and displays the updates details
+ """
+ msg = self.tr("The Bitmask app is ready to update, please"
+ " restart the application.")
+
+ # We assume that if there is nothing in the contents, then
+ # the Bitmask bundle is what needs updating.
+ if len(self._updates_content) > 0:
+ files = self._updates_content.split(", ")
+ files_str = ""
+ for f in files:
+ final_name = f.replace("/data/", "")
+ final_name = final_name.replace(".thp", "")
+ files_str += final_name
+ files_str += "\n"
+ msg += self.tr(" The following components will be updated:\n%s") \
+ % (files_str,)
+
+ QtGui.QMessageBox.information(self,
+ self.tr("Updates available"),
+ msg)
+
+ def _finish_init(self):
+ """
+ SLOT
+ TRIGGERS:
+ self._wizard.accepted
+
+ Also called at the end of the constructor if not first run,
+ and after _rejected_wizard if not first run.
+
+ Implements the behavior after either constructing the
+ mainwindow object, loading the saved user/password, or after
+ the wizard has been executed.
+ """
+ # XXX: May be this can be divided into two methods?
+
+ self._login_widget.set_providers(self._configured_providers())
+ self._show_systray()
+ self.show()
+ if IS_MAC:
+ self.raise_()
+
+ if self._wizard:
+ possible_username = self._wizard.get_username()
+ possible_password = self._wizard.get_password()
+
+ # select the configured provider in the combo box
+ domain = self._wizard.get_domain()
+ self._login_widget.select_provider_by_name(domain)
+
+ self._login_widget.set_remember(self._wizard.get_remember())
+ self._enabled_services = list(self._wizard.get_services())
+ self._settings.set_enabled_services(
+ self._login_widget.get_selected_provider(),
+ self._enabled_services)
+ if possible_username is not None:
+ self._login_widget.set_user(possible_username)
+ if possible_password is not None:
+ self._login_widget.set_password(possible_password)
+ self._login()
+ self._wizard = None
+ self._settings.set_properprovider(True)
+ else:
+ self._try_autostart_eip()
+ if not self._settings.get_remember():
+ # nothing to do here
+ return
+
+ saved_user = self._settings.get_user()
+
+ try:
+ username, domain = saved_user.split('@')
+ except (ValueError, AttributeError) as e:
+ # if the saved_user does not contain an '@' or its None
+ logger.error('Username@provider malformed. %r' % (e, ))
+ saved_user = None
+
+ if saved_user is not None and has_keyring():
+ # fill the username
+ self._login_widget.set_user(username)
+
+ # select the configured provider in the combo box
+ self._login_widget.select_provider_by_name(domain)
+
+ self._login_widget.set_remember(True)
+
+ saved_password = None
+ try:
+ saved_password = keyring.get_password(self.KEYRING_KEY,
+ saved_user
+ .encode("utf8"))
+ except ValueError, e:
+ logger.debug("Incorrect Password. %r." % (e,))
+
+ if saved_password is not None:
+ self._login_widget.set_password(
+ saved_password.decode("utf8"))
+ self._login()
+
+ def _try_autostart_eip(self):
+ """
+ Tries to autostart EIP
+ """
+ default_provider = self._settings.get_defaultprovider()
+
+ if default_provider is None:
+ logger.info("Cannot autostart Encrypted Internet because there is "
+ "no default provider configured")
+ return
+
+ self._action_eip_provider.setText(default_provider)
+
+ self._enabled_services = self._settings.get_enabled_services(
+ default_provider)
+
+ if self._provisional_provider_config.load(
+ os.path.join("leap",
+ "providers",
+ default_provider,
+ "provider.json")):
+ self._download_eip_config()
+ else:
+ # XXX: Display a proper message to the user
+ logger.error("Unable to load %s config, cannot autostart." %
+ (default_provider,))
+
+ def _show_systray(self):
+ """
+ Sets up the systray icon
+ """
+ if self._systray is not None:
+ self._systray.setVisible(True)
+ return
+
+ # Placeholder actions
+ # They are temporary to display the tray as designed
+ preferences_action = QtGui.QAction(self.tr("Preferences"), self)
+ preferences_action.setEnabled(False)
+ help_action = QtGui.QAction(self.tr("Help"), self)
+ help_action.setEnabled(False)
+
+ systrayMenu = QtGui.QMenu(self)
+ systrayMenu.addAction(self._action_visible)
+ systrayMenu.addSeparator()
+ systrayMenu.addAction(self._action_eip_provider)
+ systrayMenu.addAction(self._action_eip_status)
+ systrayMenu.addAction(self._action_eip_startstop)
+ systrayMenu.addSeparator()
+ systrayMenu.addAction(preferences_action)
+ systrayMenu.addAction(help_action)
+ systrayMenu.addSeparator()
+ systrayMenu.addAction(self.ui.action_log_out)
+ systrayMenu.addAction(self.ui.action_quit)
+ self._systray = QtGui.QSystemTrayIcon(self)
+ self._systray.setContextMenu(systrayMenu)
+ self._systray.setIcon(self._status_panel.ERROR_ICON_TRAY)
+ self._systray.setVisible(True)
+ self._systray.activated.connect(self._tray_activated)
+
+ self._status_panel.set_systray(self._systray)
+
+ def _tray_activated(self, reason=None):
+ """
+ SLOT
+ TRIGGER: self._systray.activated
+
+ Displays the context menu from the tray icon
+ """
+ self._update_hideshow_menu()
+
+ context_menu = self._systray.contextMenu()
+ if not IS_MAC:
+ # for some reason, context_menu.show()
+ # is failing in a way beyond my understanding.
+ # (not working the first time it's clicked).
+ # this works however.
+ context_menu.exec_(self._systray.geometry().center())
+
+ def _update_hideshow_menu(self):
+ """
+ Updates the Hide/Show main window menu text based on the
+ visibility of the window.
+ """
+ get_action = lambda visible: (
+ self.tr("Show Main Window"),
+ self.tr("Hide Main Window"))[int(visible)]
+
+ # set labels
+ visible = self.isVisible() and self.isActiveWindow()
+ self._action_visible.setText(get_action(visible))
+
+ def _toggle_visible(self):
+ """
+ SLOT
+ TRIGGER: self._action_visible.triggered
+
+ Toggles the window visibility
+ """
+ visible = self.isVisible() and self.isActiveWindow()
+ if not visible:
+ self.show()
+ self.activateWindow()
+ self.raise_()
+ else:
+ self.hide()
+
+ self._update_hideshow_menu()
+
+ def _center_window(self):
+ """
+ Centers the mainwindow based on the desktop geometry
+ """
+ geometry = self._settings.get_geometry()
+ state = self._settings.get_windowstate()
+
+ if geometry is None:
+ app = QtGui.QApplication.instance()
+ width = app.desktop().width()
+ height = app.desktop().height()
+ window_width = self.size().width()
+ window_height = self.size().height()
+ x = (width / 2.0) - (window_width / 2.0)
+ y = (height / 2.0) - (window_height / 2.0)
+ self.move(x, y)
+ else:
+ self.restoreGeometry(geometry)
+
+ if state is not None:
+ self.restoreState(state)
+
+ def _about(self):
+ """
+ SLOT
+ TRIGGERS: self.ui.action_about_leap.triggered
+
+ Display the About Bitmask dialog
+ """
+ QtGui.QMessageBox.about(
+ self, self.tr("About Bitmask - %s") % (VERSION,),
+ self.tr("Version: <b>%s</b><br>"
+ "<br>"
+ "Bitmask is the Desktop client application for "
+ "the LEAP platform, supporting encrypted internet "
+ "proxy, secure email, and secure chat (coming soon).<br>"
+ "<br>"
+ "LEAP is a non-profit dedicated to giving "
+ "all internet users access to secure "
+ "communication. Our focus is on adapting "
+ "encryption technology to make it easy to use "
+ "and widely available. <br>"
+ "<br>"
+ "<a href='https://leap.se'>More about LEAP"
+ "</a>") % (VERSION,))
+
+ def changeEvent(self, e):
+ """
+ Reimplements the changeEvent method to minimize to tray
+ """
+ if QtGui.QSystemTrayIcon.isSystemTrayAvailable() and \
+ e.type() == QtCore.QEvent.WindowStateChange and \
+ self.isMinimized():
+ self._toggle_visible()
+ e.accept()
+ return
+ QtGui.QMainWindow.changeEvent(self, e)
+
+ def closeEvent(self, e):
+ """
+ Reimplementation of closeEvent to close to tray
+ """
+ if QtGui.QSystemTrayIcon.isSystemTrayAvailable() and \
+ not self._really_quit:
+ self._toggle_visible()
+ e.ignore()
+ return
+
+ self._settings.set_geometry(self.saveGeometry())
+ self._settings.set_windowstate(self.saveState())
+
+ QtGui.QMainWindow.closeEvent(self, e)
+
+ def _configured_providers(self):
+ """
+ Returns the available providers based on the file structure
+
+ :rtype: list
+ """
+
+ # TODO: check which providers have a valid certificate among
+ # other things, not just the directories
+ providers = []
+ try:
+ providers = os.listdir(
+ os.path.join(self._provider_config.get_path_prefix(),
+ "leap",
+ "providers"))
+ except Exception as e:
+ logger.debug("Error listing providers, assume there are none. %r"
+ % (e,))
+
+ return providers
+
+ def _first_run(self):
+ """
+ Returns True if there are no configured providers. False otherwise
+
+ :rtype: bool
+ """
+ has_provider_on_disk = len(self._configured_providers()) != 0
+ is_proper_provider = self._settings.get_properprovider()
+ return not (has_provider_on_disk and is_proper_provider)
+
+ def _download_provider_config(self):
+ """
+ Starts the bootstrapping sequence. It will download the
+ provider configuration if it's not present, otherwise will
+ emit the corresponding signals inmediately
+ """
+ provider = self._login_widget.get_selected_provider()
+
+ pb = self._provider_bootstrapper
+ d = pb.run_provider_select_checks(provider, download_if_needed=True)
+ self._download_provider_defer = d
+
+ def _load_provider_config(self, data):
+ """
+ SLOT
+ TRIGGER: self._provider_bootstrapper.download_provider_info
+
+ Once the provider config has been downloaded, this loads the
+ self._provider_config instance with it and starts the second
+ part of the bootstrapping sequence
+
+ :param data: result from the last stage of the
+ run_provider_select_checks
+ :type data: dict
+ """
+ if data[self._provider_bootstrapper.PASSED_KEY]:
+ provider = self._login_widget.get_selected_provider()
+
+ # If there's no loaded provider or
+ # we want to connect to other provider...
+ if (not self._provider_config.loaded() or
+ self._provider_config.get_domain() != provider):
+ self._provider_config.load(
+ os.path.join("leap", "providers",
+ provider, "provider.json"))
+
+ if self._provider_config.loaded():
+ self._provider_bootstrapper.run_provider_setup_checks(
+ self._provider_config,
+ download_if_needed=True)
+ else:
+ self._login_widget.set_status(
+ self.tr("Unable to login: Problem with provider"))
+ logger.error("Could not load provider configuration.")
+ self._login_widget.set_enabled(True)
+ else:
+ self._login_widget.set_status(
+ self.tr("Unable to login: Problem with provider"))
+ logger.error(data[self._provider_bootstrapper.ERROR_KEY])
+ self._login_widget.set_enabled(True)
+
+ def _login(self):
+ """
+ SLOT
+ TRIGGERS:
+ self._login_widget.login
+
+ Starts the login sequence. Which involves bootstrapping the
+ selected provider if the selection is valid (not empty), then
+ start the SRP authentication, and as the last step
+ bootstrapping the EIP service
+ """
+ leap_assert(self._provider_config, "We need a provider config")
+
+ username = self._login_widget.get_user()
+ password = self._login_widget.get_password()
+ provider = self._login_widget.get_selected_provider()
+
+ self._enabled_services = self._settings.get_enabled_services(
+ self._login_widget.get_selected_provider())
+
+ if len(provider) == 0:
+ self._login_widget.set_status(
+ self.tr("Please select a valid provider"))
+ return
+
+ if len(username) == 0:
+ self._login_widget.set_status(
+ self.tr("Please provide a valid username"))
+ return
+
+ if len(password) == 0:
+ self._login_widget.set_status(
+ self.tr("Please provide a valid Password"))
+ return
+
+ self._login_widget.set_status(self.tr("Logging in..."), error=False)
+ self._login_widget.set_enabled(False)
+
+ if self._login_widget.get_remember() and has_keyring():
+ # in the keyring and in the settings
+ # we store the value 'usename@provider'
+ username_domain = (username + '@' + provider).encode("utf8")
+ try:
+ keyring.set_password(self.KEYRING_KEY,
+ username_domain,
+ password.encode("utf8"))
+ # Only save the username if it was saved correctly in
+ # the keyring
+ self._settings.set_user(username_domain)
+ except Exception as e:
+ logger.error("Problem saving data to keyring. %r"
+ % (e,))
+
+ self._download_provider_config()
+
+ def _cancel_login(self):
+ """
+ SLOT
+ TRIGGERS:
+ self._login_widget.cancel_login
+
+ Stops the login sequence.
+ """
+ logger.debug("Cancelling log in.")
+
+ if self._download_provider_defer:
+ logger.debug("Cancelling download provider defer.")
+ self._download_provider_defer.cancel()
+
+ if self._login_defer:
+ logger.debug("Cancelling login defer.")
+ self._login_defer.cancel()
+
+ def _provider_config_loaded(self, data):
+ """
+ SLOT
+ TRIGGER: self._provider_bootstrapper.check_api_certificate
+
+ Once the provider configuration is loaded, this starts the SRP
+ authentication
+ """
+ leap_assert(self._provider_config, "We need a provider config!")
+
+ if data[self._provider_bootstrapper.PASSED_KEY]:
+ username = self._login_widget.get_user().encode("utf8")
+ password = self._login_widget.get_password().encode("utf8")
+
+ if self._srp_auth is None:
+ self._srp_auth = SRPAuth(self._provider_config)
+ self._srp_auth.authentication_finished.connect(
+ self._authentication_finished)
+ self._srp_auth.logout_finished.connect(
+ self._done_logging_out)
+
+ # TODO: Add errback!
+ self._login_defer = self._srp_auth.authenticate(username, password)
+ else:
+ self._login_widget.set_status(
+ "Unable to login: Problem with provider")
+ logger.error(data[self._provider_bootstrapper.ERROR_KEY])
+ self._login_widget.set_enabled(True)
+
+ def _authentication_finished(self, ok, message):
+ """
+ SLOT
+ TRIGGER: self._srp_auth.authentication_finished
+
+ Once the user is properly authenticated, try starting the EIP
+ service
+ """
+
+ # In general we want to "filter" likely complicated error
+ # messages, but in this case, the messages make more sense as
+ # they come. Since they are "Unknown user" or "Unknown
+ # password"
+ self._login_widget.set_status(message, error=not ok)
+
+ if ok:
+ self._logged_user = self._login_widget.get_user()
+ self.ui.action_log_out.setEnabled(True)
+ # We leave a bit of room for the user to see the
+ # "Succeeded" message and then we switch to the EIP status
+ # panel
+ QtCore.QTimer.singleShot(1000, self._switch_to_status)
+ self._login_defer = None
+ else:
+ self._login_widget.set_enabled(True)
+
+ def _switch_to_status(self):
+ """
+ Changes the stackedWidget index to the EIP status one and
+ triggers the eip bootstrapping
+ """
+ if not self._already_started_eip:
+ self._status_panel.set_provider(
+ "%s@%s" % (self._login_widget.get_user(),
+ self._get_best_provider_config().get_domain()))
+
+ self.ui.stackedWidget.setCurrentIndex(self.EIP_STATUS_INDEX)
+
+ self._soledad_bootstrapper.run_soledad_setup_checks(
+ self._provider_config,
+ self._login_widget.get_user(),
+ self._login_widget.get_password(),
+ download_if_needed=True,
+ standalone=self._standalone)
+
+ self._download_eip_config()
+
+ def _soledad_intermediate_stage(self, data):
+ """
+ SLOT
+ TRIGGERS:
+ self._soledad_bootstrapper.download_config
+
+ If there was a problem, displays it, otherwise it does nothing.
+ This is used for intermediate bootstrapping stages, in case
+ they fail.
+ """
+ passed = data[self._soledad_bootstrapper.PASSED_KEY]
+ if not passed:
+ # TODO: display in the GUI:
+ # should pass signal to a slot in status_panel
+ # that sets the global status
+ logger.warning("Soledad failed to start: %s" %
+ (data[self._soledad_bootstrapper.ERROR_KEY],))
+
+ def _soledad_bootstrapped_stage(self, data):
+ """
+ SLOT
+ TRIGGERS:
+ self._soledad_bootstrapper.gen_key
+
+ If there was a problem, displays it, otherwise it does nothing.
+ This is used for intermediate bootstrapping stages, in case
+ they fail.
+
+ :param data: result from the bootstrapping stage for Soledad
+ :type data: dict
+ """
+ passed = data[self._soledad_bootstrapper.PASSED_KEY]
+ if not passed:
+ logger.error(data[self._soledad_bootstrapper.ERROR_KEY])
+ return
+
+ logger.debug("Done bootstrapping Soledad")
+
+ self._soledad = self._soledad_bootstrapper.soledad
+ self._keymanager = self._soledad_bootstrapper.keymanager
+
+ # Ok, now soledad is ready, so we can allow other things that
+ # depend on soledad to start.
+
+ # this will trigger start_imap_service
+ self.soledad_ready.emit()
+
+ # TODO connect all these activations to the soledad_ready
+ # signal so the logic is clearer to follow.
+
+ if self._provider_config.provides_mx() and \
+ self._enabled_services.count(self.MX_SERVICE) > 0:
+ self._smtp_bootstrapper.run_smtp_setup_checks(
+ self._provider_config,
+ self._smtp_config,
+ True)
+ else:
+ if self._enabled_services.count(self.MX_SERVICE) > 0:
+ pass # TODO: show MX status
+ #self._status_panel.set_eip_status(
+ # self.tr("%s does not support MX") %
+ # (self._provider_config.get_domain(),),
+ # error=True)
+ else:
+ pass # TODO: show MX status
+ #self._status_panel.set_eip_status(
+ # self.tr("MX is disabled"))
+
+ # Service control methods: smtp
+
+ def _smtp_bootstrapped_stage(self, data):
+ """
+ SLOT
+ TRIGGERS:
+ self._smtp_bootstrapper.download_config
+
+ If there was a problem, displays it, otherwise it does nothing.
+ This is used for intermediate bootstrapping stages, in case
+ they fail.
+
+ :param data: result from the bootstrapping stage for Soledad
+ :type data: dict
+ """
+ passed = data[self._smtp_bootstrapper.PASSED_KEY]
+ if not passed:
+ logger.error(data[self._smtp_bootstrapper.ERROR_KEY])
+ return
+ logger.debug("Done bootstrapping SMTP")
+
+ hosts = self._smtp_config.get_hosts()
+ # TODO: handle more than one host and define how to choose
+ if len(hosts) > 0:
+ hostname = hosts.keys()[0]
+ logger.debug("Using hostname %s for SMTP" % (hostname,))
+ host = hosts[hostname][self.IP_KEY].encode("utf-8")
+ port = hosts[hostname][self.PORT_KEY]
+ # TODO: pick local smtp port in a better way
+ # TODO: Make the encrypted_only configurable
+
+ from leap.mail.smtp import setup_smtp_relay
+ client_cert = self._eip_config.get_client_cert_path(
+ self._provider_config)
+ setup_smtp_relay(port=2013,
+ keymanager=self._keymanager,
+ smtp_host=host,
+ smtp_port=port,
+ smtp_cert=client_cert,
+ smtp_key=client_cert,
+ encrypted_only=False)
+
+ def _start_imap_service(self):
+ """
+ SLOT
+ TRIGGERS:
+ soledad_ready
+ """
+ logger.debug('Starting imap service')
+
+ self._imap_service = imap.start_imap_service(
+ self._soledad,
+ self._keymanager)
+
+ def _get_socket_host(self):
+ """
+ Returns the socket and port to be used for VPN
+
+ :rtype: tuple (str, str) (host, port)
+ """
+
+ # TODO: make this properly multiplatform
+
+ if platform.system() == "Windows":
+ host = "localhost"
+ port = "9876"
+ else:
+ host = os.path.join(tempfile.mkdtemp(prefix="leap-tmp"),
+ 'openvpn.socket')
+ port = "unix"
+
+ return host, port
+
+ def _start_eip(self):
+ """
+ SLOT
+ TRIGGERS:
+ self._status_panel.start_eip
+ self._action_eip_startstop.triggered
+ or called from _finish_eip_bootstrap
+
+ Starts EIP
+ """
+ self._status_panel.eip_pre_up()
+ self.user_stopped_eip = False
+ provider_config = self._get_best_provider_config()
+
+ try:
+ host, port = self._get_socket_host()
+ self._vpn.start(eipconfig=self._eip_config,
+ providerconfig=provider_config,
+ socket_host=host,
+ socket_port=port)
+
+ self._settings.set_defaultprovider(
+ provider_config.get_domain())
+
+ provider = provider_config.get_domain()
+ if self._logged_user is not None:
+ provider = "%s@%s" % (self._logged_user, provider)
+
+ self._status_panel.set_provider(provider)
+
+ self._action_eip_provider.setText(provider_config.get_domain())
+
+ self._status_panel.eip_started()
+
+ # XXX refactor into status_panel method?
+ self._action_eip_startstop.setText(self.tr("Turn OFF"))
+ self._action_eip_startstop.disconnect(self)
+ self._action_eip_startstop.triggered.connect(
+ self._stop_eip)
+ except EIPNoPolkitAuthAgentAvailable:
+ self._status_panel.set_global_status(
+ # XXX this should change to polkit-kde where
+ # applicable.
+ self.tr("We could not find any "
+ "authentication "
+ "agent in your system.<br/>"
+ "Make sure you have "
+ "<b>polkit-gnome-authentication-"
+ "agent-1</b> "
+ "running and try again."),
+ error=True)
+ self._set_eipstatus_off()
+ except EIPNoTunKextLoaded:
+ self._status_panel.set_global_status(
+ self.tr("Encrypted Internet cannot be started because "
+ "the tuntap extension is not installed properly "
+ "in your system."))
+ self._set_eipstatus_off()
+ except EIPNoPkexecAvailable:
+ self._status_panel.set_global_status(
+ self.tr("We could not find <b>pkexec</b> "
+ "in your system."),
+ error=True)
+ self._set_eipstatus_off()
+ except OpenVPNNotFoundException:
+ self._status_panel.set_global_status(
+ self.tr("We could not find openvpn binary."),
+ error=True)
+ self._set_eipstatus_off()
+ except OpenVPNAlreadyRunning as e:
+ self._status_panel.set_global_status(
+ self.tr("Another openvpn instance is already running, and "
+ "could not be stopped."),
+ error=True)
+ self._set_eipstatus_off()
+ except AlienOpenVPNAlreadyRunning as e:
+ self._status_panel.set_global_status(
+ self.tr("Another openvpn instance is already running, and "
+ "could not be stopped because it was not launched by "
+ "Bitmask. Please stop it and try again."),
+ error=True)
+ self._set_eipstatus_off()
+ except VPNLauncherException as e:
+ # XXX We should implement again translatable exceptions so
+ # we can pass a translatable string to the panel (usermessage attr)
+ self._status_panel.set_global_status("%s" % (e,), error=True)
+ self._set_eipstatus_off()
+ else:
+ self._already_started_eip = True
+
+ def _set_eipstatus_off(self):
+ """
+ Sets eip status to off
+ """
+ self._status_panel.set_eip_status(self.tr("OFF"), error=True)
+ self._status_panel.set_eip_status_icon("error")
+ self._status_panel.set_startstop_enabled(True)
+ self._status_panel.eip_stopped()
+
+ self._set_action_eipstart_off()
+
+ def _set_action_eipstart_off(self):
+ """
+ Sets eip startstop action to OFF status.
+ """
+ self._action_eip_startstop.setText(self.tr("Turn ON"))
+ self._action_eip_startstop.disconnect(self)
+ self._action_eip_startstop.triggered.connect(
+ self._start_eip)
+
+ def _stop_eip(self, abnormal=False):
+ """
+ SLOT
+ TRIGGERS:
+ self._status_panel.stop_eip
+ self._action_eip_startstop.triggered
+ or called from _eip_finished
+
+ Stops vpn process and makes gui adjustments to reflect
+ the change of state.
+
+ :param abnormal: whether this was an abnormal termination.
+ :type abnormal: bool
+ """
+ if abnormal:
+ logger.warning("Abnormal EIP termination.")
+
+ self.user_stopped_eip = True
+ self._vpn.terminate()
+
+ self._set_eipstatus_off()
+
+ self._already_started_eip = False
+ self._settings.set_defaultprovider(None)
+ if self._logged_user:
+ self._status_panel.set_provider(
+ "%s@%s" % (self._logged_user,
+ self._get_best_provider_config().get_domain()))
+
+ def _get_best_provider_config(self):
+ """
+ Returns the best ProviderConfig to use at a moment. We may
+ have to use self._provider_config or
+ self._provisional_provider_config depending on the start
+ status.
+
+ :rtype: ProviderConfig
+ """
+ leap_assert(self._provider_config is not None or
+ self._provisional_provider_config is not None,
+ "We need a provider config")
+
+ provider_config = None
+ if self._provider_config.loaded():
+ provider_config = self._provider_config
+ elif self._provisional_provider_config.loaded():
+ provider_config = self._provisional_provider_config
+ else:
+ leap_assert(False, "We could not find any usable ProviderConfig.")
+
+ return provider_config
+
+ def _download_eip_config(self):
+ """
+ Starts the EIP bootstrapping sequence
+ """
+ leap_assert(self._eip_bootstrapper, "We need an eip bootstrapper!")
+
+ provider_config = self._get_best_provider_config()
+
+ if provider_config.provides_eip() and \
+ self._enabled_services.count(self.OPENVPN_SERVICE) > 0 and \
+ not self._already_started_eip:
+
+ self._status_panel.set_eip_status(
+ self.tr("Starting..."))
+ self._eip_bootstrapper.run_eip_setup_checks(
+ provider_config,
+ download_if_needed=True)
+ self._already_started_eip = True
+ elif not self._already_started_eip:
+ if self._enabled_services.count(self.OPENVPN_SERVICE) > 0:
+ self._status_panel.set_eip_status(
+ self.tr("Not supported"),
+ error=True)
+ else:
+ self._status_panel.set_eip_status(self.tr("Disabled"))
+ self._status_panel.set_startstop_enabled(False)
+
+ def _finish_eip_bootstrap(self, data):
+ """
+ SLOT
+ TRIGGER: self._eip_bootstrapper.download_client_certificate
+
+ Starts the VPN thread if the eip configuration is properly
+ loaded
+ """
+ leap_assert(self._eip_config, "We need an eip config!")
+ passed = data[self._eip_bootstrapper.PASSED_KEY]
+
+ if not passed:
+ error_msg = self.tr("There was a problem with the provider")
+ self._status_panel.set_eip_status(error_msg, error=True)
+ logger.error(data[self._eip_bootstrapper.ERROR_KEY])
+ self._already_started_eip = False
+ return
+
+ provider_config = self._get_best_provider_config()
+
+ domain = provider_config.get_domain()
+
+ loaded = self._eip_config.loaded()
+ if not loaded:
+ eip_config_path = os.path.join("leap", "providers",
+ domain, "eip-service.json")
+ api_version = provider_config.get_api_version()
+ self._eip_config.set_api_version(api_version)
+ loaded = self._eip_config.load(eip_config_path)
+
+ if loaded:
+ self._start_eip()
+ else:
+ self._status_panel.set_eip_status(
+ self.tr("Could not load Encrypted Internet "
+ "Configuration."),
+ error=True)
+
+ def _logout(self):
+ """
+ SLOT
+ TRIGGER: self.ui.action_log_out.triggered
+
+ Starts the logout sequence
+ """
+ # XXX: If other defers are doing authenticated stuff, this
+ # might conflict with those. CHECK!
+ threads.deferToThread(self._srp_auth.logout)
+
+ def _done_logging_out(self, ok, message):
+ """
+ SLOT
+ TRIGGER: self._srp_auth.logout_finished
+
+ Switches the stackedWidget back to the login stage after
+ logging out
+ """
+ self._logged_user = None
+ self.ui.action_log_out.setEnabled(False)
+ self.ui.stackedWidget.setCurrentIndex(self.LOGIN_INDEX)
+ self._login_widget.set_password("")
+ self._login_widget.set_enabled(True)
+ self._login_widget.set_status("")
+
+ def _intermediate_stage(self, data):
+ """
+ SLOT
+ TRIGGERS:
+ self._provider_bootstrapper.name_resolution
+ self._provider_bootstrapper.https_connection
+ self._provider_bootstrapper.download_ca_cert
+ self._eip_bootstrapper.download_config
+
+ If there was a problem, displays it, otherwise it does nothing.
+ This is used for intermediate bootstrapping stages, in case
+ they fail.
+ """
+ passed = data[self._provider_bootstrapper.PASSED_KEY]
+ if not passed:
+ self._login_widget.set_enabled(True)
+ self._login_widget.set_status(
+ self.tr("Unable to connect: Problem with provider"))
+ logger.error(data[self._provider_bootstrapper.ERROR_KEY])
+
+ def _eip_intermediate_stage(self, data):
+ """
+ SLOT
+ TRIGGERS:
+ self._eip_bootstrapper.download_config
+
+ If there was a problem, displays it, otherwise it does nothing.
+ This is used for intermediate bootstrapping stages, in case
+ they fail.
+ """
+ passed = data[self._provider_bootstrapper.PASSED_KEY]
+ if not passed:
+ self._login_widget.set_status(
+ self.tr("Unable to connect: Problem with provider"))
+ logger.error(data[self._provider_bootstrapper.ERROR_KEY])
+ self._already_started_eip = False
+
+ def _eip_finished(self, exitCode):
+ """
+ SLOT
+ TRIGGERS:
+ self._vpn.process_finished
+
+ Triggered when the EIP/VPN process finishes to set the UI
+ accordingly.
+ """
+ logger.info("VPN process finished with exitCode %s..."
+ % (exitCode,))
+
+ # Ideally we would have the right exit code here,
+ # but the use of different wrappers (pkexec, cocoasudo) swallows
+ # the openvpn exit code so we get zero exit in some cases where we
+ # shouldn't. As a workaround we just use a flag to indicate
+ # a purposeful switch off, and mark everything else as unexpected.
+
+ # In the near future we should trigger a native notification from here,
+ # since the user really really wants to know she is unprotected asap.
+ # And the right thing to do will be to fail-close.
+
+ # TODO we should have a way of parsing the latest lines in the vpn
+ # log buffer so we can have a more precise idea of which type
+ # of error did we have (server side, local problem, etc)
+ abnormal = True
+
+ # XXX check if these exitCodes are pkexec/cocoasudo specific
+ if exitCode in (126, 127):
+ self._status_panel.set_global_status(
+ self.tr("Encrypted Internet could not be launched "
+ "because you did not authenticate properly."),
+ error=True)
+ self._vpn.killit()
+ elif exitCode != 0 or not self.user_stopped_eip:
+ self._status_panel.set_global_status(
+ self.tr("Encrypted Internet finished in an "
+ "unexpected manner!"), error=True)
+ else:
+ abnormal = False
+ if exitCode == 0 and IS_MAC:
+ # XXX remove this warning after I fix cocoasudo.
+ logger.warning("The above exit code MIGHT BE WRONG.")
+ self._stop_eip(abnormal)
+
+ def _on_raise_window_event(self, req):
+ """
+ Callback for the raise window event
+ """
+ if IS_WIN:
+ raise_window_ack()
+ self.raise_window.emit()
+
+ def _do_raise_mainwindow(self):
+ """
+ SLOT
+ TRIGGERS:
+ self._on_raise_window_event
+
+ Triggered when we receive a RAISE_WINDOW event.
+ """
+ TOPFLAG = QtCore.Qt.WindowStaysOnTopHint
+ self.setWindowFlags(self.windowFlags() | TOPFLAG)
+ self.show()
+ self.setWindowFlags(self.windowFlags() & ~TOPFLAG)
+ self.show()
+ if IS_MAC:
+ self.raise_()
+
+ def _cleanup_pidfiles(self):
+ """
+ Removes lockfiles on a clean shutdown.
+
+ Triggered after aboutToQuit signal.
+ """
+ if IS_WIN:
+ WindowsLock.release_all_locks()
+
+ def _cleanup_and_quit(self):
+ """
+ Call all the cleanup actions in a serialized way.
+ Should be called from the quit function.
+ """
+ logger.debug('About to quit, doing cleanup...')
+
+ if self._imap_service is not None:
+ self._imap_service.stop()
+
+ if self._srp_auth is not None:
+ if self._srp_auth.get_session_id() is not None or \
+ self._srp_auth.get_token() is not None:
+ # XXX this can timeout after loong time: See #3368
+ self._srp_auth.logout()
+
+ if self._soledad:
+ logger.debug("Closing soledad...")
+ self._soledad.close()
+ else:
+ logger.error("No instance of soledad was found.")
+
+ logger.debug('Terminating vpn')
+ self._vpn.terminate(shutdown=True)
+
+ if self._login_defer:
+ logger.debug("Cancelling login defer.")
+ self._login_defer.cancel()
+
+ if self._download_provider_defer:
+ logger.debug("Cancelling download provider defer.")
+ self._download_provider_defer.cancel()
+
+ # TODO missing any more cancels?
+
+ logger.debug('Cleaning pidfiles')
+ self._cleanup_pidfiles()
+
+ def quit(self):
+ """
+ Cleanup and tidely close the main window before quitting.
+ """
+ # TODO: separate the shutting down of services from the
+ # UI stuff.
+ self._cleanup_and_quit()
+
+ self._really_quit = True
+
+ if self._wizard:
+ self._wizard.close()
+
+ if self._logger_window:
+ self._logger_window.close()
+
+ self.close()
+
+ if self._quit_callback:
+ self._quit_callback()
+
+ logger.debug('Bye.')
+
+
+if __name__ == "__main__":
+ import signal
+
+ def sigint_handler(*args, **kwargs):
+ logger.debug('SIGINT catched. shutting down...')
+ mainwindow = args[0]
+ mainwindow.quit()
+
+ import sys
+
+ logger = logging.getLogger(name='leap')
+ logger.setLevel(logging.DEBUG)
+ console = logging.StreamHandler()
+ console.setLevel(logging.DEBUG)
+ formatter = logging.Formatter(
+ '%(asctime)s '
+ '- %(name)s - %(levelname)s - %(message)s')
+ console.setFormatter(formatter)
+ logger.addHandler(console)
+
+ app = QtGui.QApplication(sys.argv)
+ mainwindow = MainWindow()
+ mainwindow.show()
+
+ timer = QtCore.QTimer()
+ timer.start(500)
+ timer.timeout.connect(lambda: None)
+
+ sigint = partial(sigint_handler, mainwindow)
+ signal.signal(signal.SIGINT, sigint)
+
+ sys.exit(app.exec_())
diff --git a/src/leap/bitmask/gui/statuspanel.py b/src/leap/bitmask/gui/statuspanel.py
new file mode 100644
index 00000000..8f5427ad
--- /dev/null
+++ b/src/leap/bitmask/gui/statuspanel.py
@@ -0,0 +1,460 @@
+# -*- coding: utf-8 -*-
+# statuspanel.py
+# Copyright (C) 2013 LEAP
+#
+# 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/>.
+
+"""
+Status Panel widget implementation
+"""
+import logging
+
+from datetime import datetime
+from functools import partial
+
+from PySide import QtCore, QtGui
+
+from leap.bitmask.services.eip.vpnprocess import VPNManager
+from leap.bitmask.platform_init import IS_WIN, IS_LINUX
+from leap.bitmask.util import first
+from leap.common.check import leap_assert_type
+
+from ui_statuspanel import Ui_StatusPanel
+
+logger = logging.getLogger(__name__)
+
+
+class RateMovingAverage(object):
+ """
+ Moving window average for calculating
+ upload and download rates.
+ """
+ SAMPLE_SIZE = 5
+
+ def __init__(self):
+ """
+ Initializes an empty array of fixed size
+ """
+ self.reset()
+
+ def reset(self):
+ self._data = [None for i in xrange(self.SAMPLE_SIZE)]
+
+ def append(self, x):
+ """
+ Appends a new data point to the collection.
+
+ :param x: A tuple containing timestamp and traffic points
+ in the form (timestamp, traffic)
+ :type x: tuple
+ """
+ self._data.pop(0)
+ self._data.append(x)
+
+ def get(self):
+ """
+ Gets the collection.
+ """
+ return self._data
+
+ def get_average(self):
+ """
+ Gets the moving average.
+ """
+ data = filter(None, self.get())
+ traff = [traffic for (ts, traffic) in data]
+ times = [ts for (ts, traffic) in data]
+
+ try:
+ deltatraffic = traff[-1] - first(traff)
+ deltat = (times[-1] - first(times)).seconds
+ except IndexError:
+ deltatraffic = 0
+ deltat = 0
+
+ try:
+ rate = float(deltatraffic) / float(deltat) / 1024
+ except ZeroDivisionError:
+ rate = 0
+
+ # In some cases we get negative rates
+ if rate < 0:
+ rate = 0
+
+ return rate
+
+ def get_total(self):
+ """
+ Gets the total accumulated throughput.
+ """
+ try:
+ return self._data[-1][1] / 1024
+ except TypeError:
+ return 0
+
+
+class StatusPanelWidget(QtGui.QWidget):
+ """
+ Status widget that displays the current state of the LEAP services
+ """
+
+ start_eip = QtCore.Signal()
+ stop_eip = QtCore.Signal()
+
+ DISPLAY_TRAFFIC_RATES = True
+ RATE_STR = "%14.2f KB/s"
+ TOTAL_STR = "%14.2f Kb"
+
+ def __init__(self, parent=None):
+ QtGui.QWidget.__init__(self, parent)
+
+ self._systray = None
+ self._action_eip_status = None
+
+ self.ui = Ui_StatusPanel()
+ self.ui.setupUi(self)
+
+ self.ui.btnEipStartStop.setEnabled(False)
+ self.ui.btnEipStartStop.clicked.connect(
+ self.start_eip)
+
+ self.hide_status_box()
+
+ # Set the EIP status icons
+ self.CONNECTING_ICON = None
+ self.CONNECTED_ICON = None
+ self.ERROR_ICON = None
+ self.CONNECTING_ICON_TRAY = None
+ self.CONNECTED_ICON_TRAY = None
+ self.ERROR_ICON_TRAY = None
+ self._set_eip_icons()
+
+ self._set_traffic_rates()
+ self._make_status_clickable()
+
+ def _make_status_clickable(self):
+ """
+ Makes upload and download figures clickable.
+ """
+ onclicked = self._on_VPN_status_clicked
+ self.ui.btnUpload.clicked.connect(onclicked)
+ self.ui.btnDownload.clicked.connect(onclicked)
+
+ def _on_VPN_status_clicked(self):
+ """
+ SLOT
+ TRIGGER: self.ui.btnUpload.clicked
+ self.ui.btnDownload.clicked
+
+ Toggles between rate and total throughput display for vpn
+ status figures.
+ """
+ self.DISPLAY_TRAFFIC_RATES = not self.DISPLAY_TRAFFIC_RATES
+ self.update_vpn_status(None) # refresh
+
+ def _set_traffic_rates(self):
+ """
+ Initializes up and download rates.
+ """
+ self._up_rate = RateMovingAverage()
+ self._down_rate = RateMovingAverage()
+
+ self.ui.btnUpload.setText(self.RATE_STR % (0,))
+ self.ui.btnDownload.setText(self.RATE_STR % (0,))
+
+ def _reset_traffic_rates(self):
+ """
+ Resets up and download rates, and cleans up the labels.
+ """
+ self._up_rate.reset()
+ self._down_rate.reset()
+ self.update_vpn_status(None)
+
+ def _update_traffic_rates(self, up, down):
+ """
+ Updates up and download rates.
+
+ :param up: upload total.
+ :type up: int
+ :param down: download total.
+ :type down: int
+ """
+ ts = datetime.now()
+ self._up_rate.append((ts, up))
+ self._down_rate.append((ts, down))
+
+ def _get_traffic_rates(self):
+ """
+ Gets the traffic rates (in KB/s).
+
+ :returns: a tuple with the (up, down) rates
+ :rtype: tuple
+ """
+ up = self._up_rate
+ down = self._down_rate
+
+ return (up.get_average(), down.get_average())
+
+ def _get_traffic_totals(self):
+ """
+ Gets the traffic total throughput (in Kb).
+
+ :returns: a tuple with the (up, down) totals
+ :rtype: tuple
+ """
+ up = self._up_rate
+ down = self._down_rate
+
+ return (up.get_total(), down.get_total())
+
+ def _set_eip_icons(self):
+ """
+ Sets the EIP status icons for the main window and for the tray
+
+ MAC : dark icons
+ LINUX : dark icons in window, light icons in tray
+ WIN : light icons
+ """
+ EIP_ICONS = EIP_ICONS_TRAY = (
+ ":/images/conn_connecting-light.png",
+ ":/images/conn_connected-light.png",
+ ":/images/conn_error-light.png")
+
+ if IS_LINUX:
+ EIP_ICONS_TRAY = (
+ ":/images/conn_connecting.png",
+ ":/images/conn_connected.png",
+ ":/images/conn_error.png")
+ elif IS_WIN:
+ EIP_ICONS = EIP_ICONS_TRAY = (
+ ":/images/conn_connecting.png",
+ ":/images/conn_connected.png",
+ ":/images/conn_error.png")
+
+ self.CONNECTING_ICON = QtGui.QPixmap(EIP_ICONS[0])
+ self.CONNECTED_ICON = QtGui.QPixmap(EIP_ICONS[1])
+ self.ERROR_ICON = QtGui.QPixmap(EIP_ICONS[2])
+
+ self.CONNECTING_ICON_TRAY = QtGui.QPixmap(EIP_ICONS_TRAY[0])
+ self.CONNECTED_ICON_TRAY = QtGui.QPixmap(EIP_ICONS_TRAY[1])
+ self.ERROR_ICON_TRAY = QtGui.QPixmap(EIP_ICONS_TRAY[2])
+
+ def set_systray(self, systray):
+ """
+ Sets the systray object to use.
+
+ :param systray: Systray object
+ :type systray: QtGui.QSystemTrayIcon
+ """
+ leap_assert_type(systray, QtGui.QSystemTrayIcon)
+ self._systray = systray
+
+ def set_action_eip_startstop(self, action_eip_startstop):
+ """
+ Sets the action_eip_startstop to use.
+
+ :param action_eip_startstop: action_eip_status to be used
+ :type action_eip_startstop: QtGui.QAction
+ """
+ self._action_eip_startstop = action_eip_startstop
+
+ def set_action_eip_status(self, action_eip_status):
+ """
+ Sets the action_eip_status to use.
+
+ :param action_eip_status: action_eip_status to be used
+ :type action_eip_status: QtGui.QAction
+ """
+ leap_assert_type(action_eip_status, QtGui.QAction)
+ self._action_eip_status = action_eip_status
+
+ def set_global_status(self, status, error=False):
+ """
+ Sets the global status label.
+
+ :param status: status message
+ :type status: str or unicode
+ :param error: if the status is an erroneous one, then set this
+ to True
+ :type error: bool
+ """
+ leap_assert_type(error, bool)
+ if error:
+ status = "<font color='red'><b>%s</b></font>" % (status,)
+ self.ui.lblGlobalStatus.setText(status)
+ self.ui.globalStatusBox.show()
+
+ def hide_status_box(self):
+ """
+ Hide global status box.
+ """
+ self.ui.globalStatusBox.hide()
+
+ def set_eip_status(self, status, error=False):
+ """
+ Sets the status label at the VPN stage to status
+
+ :param status: status message
+ :type status: str or unicode
+ :param error: if the status is an erroneous one, then set this
+ to True
+ :type error: bool
+ """
+ leap_assert_type(error, bool)
+
+ self._systray.setToolTip(status)
+ if error:
+ status = "<font color='red'>%s</font>" % (status,)
+ self.ui.lblEIPStatus.setText(status)
+
+ def set_startstop_enabled(self, value):
+ """
+ Enable or disable btnEipStartStop and _action_eip_startstop
+ based on value
+
+ :param value: True for enabled, False otherwise
+ :type value: bool
+ """
+ leap_assert_type(value, bool)
+ self.ui.btnEipStartStop.setEnabled(value)
+ self._action_eip_startstop.setEnabled(value)
+
+ def eip_pre_up(self):
+ """
+ Triggered when the app activates eip.
+ Hides the status box and disables the start/stop button.
+ """
+ self.hide_status_box()
+ self.set_startstop_enabled(False)
+
+ def eip_started(self):
+ """
+ Sets the state of the widget to how it should look after EIP
+ has started
+ """
+ self.ui.btnEipStartStop.setText(self.tr("Turn OFF"))
+ self.ui.btnEipStartStop.disconnect(self)
+ self.ui.btnEipStartStop.clicked.connect(
+ self.stop_eip)
+
+ def eip_stopped(self):
+ """
+ Sets the state of the widget to how it should look after EIP
+ has stopped
+ """
+ self._reset_traffic_rates()
+ self.ui.btnEipStartStop.setText(self.tr("Turn ON"))
+ self.ui.btnEipStartStop.disconnect(self)
+ self.ui.btnEipStartStop.clicked.connect(
+ self.start_eip)
+
+ def set_icon(self, icon):
+ """
+ Sets the icon to display for EIP
+
+ :param icon: icon to display
+ :type icon: QPixmap
+ """
+ self.ui.lblVPNStatusIcon.setPixmap(icon)
+
+ def update_vpn_status(self, data):
+ """
+ SLOT
+ TRIGGER: VPN.status_changed
+
+ Updates the download/upload labels based on the data provided
+ by the VPN thread.
+
+ :param data: a dictionary with the tcp/udp write and read totals.
+ If data is None, we just will refresh the display based
+ on the previous data.
+ :type data: dict
+ """
+ if data:
+ upload = float(data[VPNManager.TCPUDP_WRITE_KEY] or "0")
+ download = float(data[VPNManager.TCPUDP_READ_KEY] or "0")
+ self._update_traffic_rates(upload, download)
+
+ if self.DISPLAY_TRAFFIC_RATES:
+ uprate, downrate = self._get_traffic_rates()
+ upload_str = self.RATE_STR % (uprate,)
+ download_str = self.RATE_STR % (downrate,)
+
+ else: # display total throughput
+ uptotal, downtotal = self._get_traffic_totals()
+ upload_str = self.TOTAL_STR % (uptotal,)
+ download_str = self.TOTAL_STR % (downtotal,)
+
+ self.ui.btnUpload.setText(upload_str)
+ self.ui.btnDownload.setText(download_str)
+
+ def update_vpn_state(self, data):
+ """
+ SLOT
+ TRIGGER: VPN.state_changed
+
+ Updates the displayed VPN state based on the data provided by
+ the VPN thread
+ """
+ status = data[VPNManager.STATUS_STEP_KEY]
+ self.set_eip_status_icon(status)
+ if status == "CONNECTED":
+ self.set_eip_status(self.tr("ON"))
+ # Only now we can properly enable the button.
+ self.set_startstop_enabled(True)
+ elif status == "AUTH":
+ self.set_eip_status(self.tr("Authenticating..."))
+ elif status == "GET_CONFIG":
+ self.set_eip_status(self.tr("Retrieving configuration..."))
+ elif status == "WAIT":
+ self.set_eip_status(self.tr("Waiting to start..."))
+ elif status == "ASSIGN_IP":
+ self.set_eip_status(self.tr("Assigning IP"))
+ elif status == "ALREADYRUNNING":
+ # Put the following calls in Qt's event queue, otherwise
+ # the UI won't update properly
+ QtCore.QTimer.singleShot(0, self.stop_eip)
+ QtCore.QTimer.singleShot(0, partial(self.set_global_status,
+ self.tr("Unable to start VPN, "
+ "it's already "
+ "running.")))
+ else:
+ self.set_eip_status(status)
+
+ def set_eip_status_icon(self, status):
+ """
+ Given a status step from the VPN thread, set the icon properly
+
+ :param status: status step
+ :type status: str
+ """
+ selected_pixmap = self.ERROR_ICON
+ selected_pixmap_tray = self.ERROR_ICON_TRAY
+ tray_message = self.tr("Encryption is OFF")
+ if status in ("WAIT", "AUTH", "GET_CONFIG",
+ "RECONNECTING", "ASSIGN_IP"):
+ selected_pixmap = self.CONNECTING_ICON
+ selected_pixmap_tray = self.CONNECTING_ICON_TRAY
+ tray_message = self.tr("Turning ON")
+ elif status in ("CONNECTED"):
+ tray_message = self.tr("Encryption is ON")
+ selected_pixmap = self.CONNECTED_ICON
+ selected_pixmap_tray = self.CONNECTED_ICON_TRAY
+
+ self.set_icon(selected_pixmap)
+ self._systray.setIcon(QtGui.QIcon(selected_pixmap_tray))
+ self._action_eip_status.setText(tray_message)
+
+ def set_provider(self, provider):
+ self.ui.lblProvider.setText(provider)
diff --git a/src/leap/bitmask/gui/twisted_main.py b/src/leap/bitmask/gui/twisted_main.py
new file mode 100644
index 00000000..c7add3ee
--- /dev/null
+++ b/src/leap/bitmask/gui/twisted_main.py
@@ -0,0 +1,60 @@
+# -*- coding: utf-8 -*-
+# twisted_main.py
+# Copyright (C) 2013 LEAP
+#
+# 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/>.
+"""
+Main functions for integration of twisted reactor
+"""
+import logging
+
+from twisted.internet import error
+
+# Resist the temptation of putting the import reactor here,
+# it will raise an "reactor already imported" error.
+
+logger = logging.getLogger(__name__)
+
+
+def start(app):
+ """
+ Start the mainloop.
+
+ :param app: the main qt QApplication instance.
+ :type app: QtCore.QApplication
+ """
+ from twisted.internet import reactor
+ logger.debug('starting twisted reactor')
+
+ # this seems to be troublesome under some
+ # unidentified settings.
+ #reactor.run()
+
+ reactor.runReturn()
+ app.exec_()
+
+
+def quit(app):
+ """
+ Stop the mainloop.
+
+ :param app: the main qt QApplication instance.
+ :type app: QtCore.QApplication
+ """
+ from twisted.internet import reactor
+ logger.debug('stopping twisted reactor')
+ try:
+ reactor.stop()
+ except error.ReactorNotRunning:
+ logger.debug('reactor not running')
diff --git a/src/leap/bitmask/gui/ui/loggerwindow.ui b/src/leap/bitmask/gui/ui/loggerwindow.ui
new file mode 100644
index 00000000..b08428a9
--- /dev/null
+++ b/src/leap/bitmask/gui/ui/loggerwindow.ui
@@ -0,0 +1,155 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<ui version="4.0">
+ <class>LoggerWindow</class>
+ <widget class="QWidget" name="LoggerWindow">
+ <property name="geometry">
+ <rect>
+ <x>0</x>
+ <y>0</y>
+ <width>648</width>
+ <height>469</height>
+ </rect>
+ </property>
+ <property name="windowTitle">
+ <string>Logs</string>
+ </property>
+ <property name="windowIcon">
+ <iconset resource="../../../../data/resources/mainwindow.qrc">
+ <normaloff>:/images/mask-icon.png</normaloff>:/images/mask-icon.png</iconset>
+ </property>
+ <layout class="QGridLayout" name="gridLayout">
+ <item row="2" column="0" colspan="2">
+ <widget class="QTextBrowser" name="txtLogHistory"/>
+ </item>
+ <item row="0" column="0" colspan="2">
+ <layout class="QHBoxLayout" name="horizontalLayout_2">
+ <item>
+ <widget class="QPushButton" name="btnDebug">
+ <property name="text">
+ <string>Debug</string>
+ </property>
+ <property name="icon">
+ <iconset resource="../../../../data/resources/loggerwindow.qrc">
+ <normaloff>:/images/oxygen-icons/script-error.png</normaloff>:/images/oxygen-icons/script-error.png</iconset>
+ </property>
+ <property name="checkable">
+ <bool>true</bool>
+ </property>
+ <property name="checked">
+ <bool>true</bool>
+ </property>
+ <property name="flat">
+ <bool>true</bool>
+ </property>
+ </widget>
+ </item>
+ <item>
+ <widget class="QPushButton" name="btnInfo">
+ <property name="text">
+ <string>Info</string>
+ </property>
+ <property name="icon">
+ <iconset resource="../../../../data/resources/loggerwindow.qrc">
+ <normaloff>:/images/oxygen-icons/dialog-information.png</normaloff>:/images/oxygen-icons/dialog-information.png</iconset>
+ </property>
+ <property name="checkable">
+ <bool>true</bool>
+ </property>
+ <property name="checked">
+ <bool>true</bool>
+ </property>
+ <property name="flat">
+ <bool>true</bool>
+ </property>
+ </widget>
+ </item>
+ <item>
+ <widget class="QPushButton" name="btnWarning">
+ <property name="text">
+ <string>Warning</string>
+ </property>
+ <property name="icon">
+ <iconset resource="../../../../data/resources/loggerwindow.qrc">
+ <normaloff>:/images/oxygen-icons/dialog-warning.png</normaloff>:/images/oxygen-icons/dialog-warning.png</iconset>
+ </property>
+ <property name="checkable">
+ <bool>true</bool>
+ </property>
+ <property name="checked">
+ <bool>true</bool>
+ </property>
+ <property name="flat">
+ <bool>true</bool>
+ </property>
+ </widget>
+ </item>
+ <item>
+ <widget class="QPushButton" name="btnError">
+ <property name="text">
+ <string>Error</string>
+ </property>
+ <property name="icon">
+ <iconset resource="../../../../data/resources/loggerwindow.qrc">
+ <normaloff>:/images/oxygen-icons/dialog-error.png</normaloff>:/images/oxygen-icons/dialog-error.png</iconset>
+ </property>
+ <property name="checkable">
+ <bool>true</bool>
+ </property>
+ <property name="checked">
+ <bool>true</bool>
+ </property>
+ <property name="flat">
+ <bool>true</bool>
+ </property>
+ </widget>
+ </item>
+ <item>
+ <widget class="QPushButton" name="btnCritical">
+ <property name="text">
+ <string>Critical</string>
+ </property>
+ <property name="icon">
+ <iconset resource="../../../../data/resources/loggerwindow.qrc">
+ <normaloff>:/images/oxygen-icons/edit-bomb.png</normaloff>:/images/oxygen-icons/edit-bomb.png</iconset>
+ </property>
+ <property name="checkable">
+ <bool>true</bool>
+ </property>
+ <property name="checked">
+ <bool>true</bool>
+ </property>
+ <property name="flat">
+ <bool>true</bool>
+ </property>
+ </widget>
+ </item>
+ <item>
+ <widget class="QPushButton" name="btnSave">
+ <property name="text">
+ <string>Save to file</string>
+ </property>
+ <property name="icon">
+ <iconset resource="../../../../data/resources/loggerwindow.qrc">
+ <normaloff>:/images/oxygen-icons/document-save-as.png</normaloff>:/images/oxygen-icons/document-save-as.png</iconset>
+ </property>
+ </widget>
+ </item>
+ </layout>
+ </item>
+ </layout>
+ </widget>
+ <tabstops>
+ <tabstop>btnDebug</tabstop>
+ <tabstop>btnInfo</tabstop>
+ <tabstop>btnWarning</tabstop>
+ <tabstop>btnError</tabstop>
+ <tabstop>btnCritical</tabstop>
+ <tabstop>btnSave</tabstop>
+ <tabstop>txtLogHistory</tabstop>
+ </tabstops>
+ <resources>
+ <include location="../../../../data/resources/loggerwindow.qrc"/>
+ <include location="../../../../data/resources/mainwindow.qrc"/>
+ </resources>
+ <connections/>
+</ui>
diff --git a/src/leap/bitmask/gui/ui/login.ui b/src/leap/bitmask/gui/ui/login.ui
new file mode 100644
index 00000000..42a6897a
--- /dev/null
+++ b/src/leap/bitmask/gui/ui/login.ui
@@ -0,0 +1,132 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<ui version="4.0">
+ <class>LoginWidget</class>
+ <widget class="QWidget" name="LoginWidget">
+ <property name="geometry">
+ <rect>
+ <x>0</x>
+ <y>0</y>
+ <width>356</width>
+ <height>223</height>
+ </rect>
+ </property>
+ <property name="windowTitle">
+ <string>Form</string>
+ </property>
+ <layout class="QGridLayout" name="gridLayout">
+ <item row="5" column="2">
+ <spacer name="horizontalSpacer">
+ <property name="orientation">
+ <enum>Qt::Horizontal</enum>
+ </property>
+ <property name="sizeHint" stdset="0">
+ <size>
+ <width>40</width>
+ <height>20</height>
+ </size>
+ </property>
+ </spacer>
+ </item>
+ <item row="1" column="1" colspan="2">
+ <widget class="QComboBox" name="cmbProviders"/>
+ </item>
+ <item row="5" column="0">
+ <spacer name="horizontalSpacer_2">
+ <property name="orientation">
+ <enum>Qt::Horizontal</enum>
+ </property>
+ <property name="sizeHint" stdset="0">
+ <size>
+ <width>40</width>
+ <height>20</height>
+ </size>
+ </property>
+ </spacer>
+ </item>
+ <item row="6" column="1">
+ <widget class="QPushButton" name="btnCreateAccount">
+ <property name="text">
+ <string>Create a new account</string>
+ </property>
+ </widget>
+ </item>
+ <item row="1" column="0">
+ <widget class="QLabel" name="label_4">
+ <property name="text">
+ <string>&lt;b&gt;Provider:&lt;/b&gt;</string>
+ </property>
+ <property name="alignment">
+ <set>Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter</set>
+ </property>
+ </widget>
+ </item>
+ <item row="3" column="1" colspan="2">
+ <widget class="QLineEdit" name="lnPassword">
+ <property name="inputMask">
+ <string/>
+ </property>
+ </widget>
+ </item>
+ <item row="2" column="1" colspan="2">
+ <widget class="QLineEdit" name="lnUser"/>
+ </item>
+ <item row="4" column="1" colspan="2">
+ <widget class="QCheckBox" name="chkRemember">
+ <property name="text">
+ <string>Remember username and password</string>
+ </property>
+ </widget>
+ </item>
+ <item row="2" column="0">
+ <widget class="QLabel" name="label_2">
+ <property name="text">
+ <string>&lt;b&gt;Username:&lt;/b&gt;</string>
+ </property>
+ <property name="alignment">
+ <set>Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter</set>
+ </property>
+ </widget>
+ </item>
+ <item row="3" column="0">
+ <widget class="QLabel" name="label_3">
+ <property name="text">
+ <string>&lt;b&gt;Password:&lt;/b&gt;</string>
+ </property>
+ <property name="alignment">
+ <set>Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter</set>
+ </property>
+ </widget>
+ </item>
+ <item row="5" column="1">
+ <widget class="QPushButton" name="btnLogin">
+ <property name="text">
+ <string>Log In</string>
+ </property>
+ </widget>
+ </item>
+ <item row="0" column="0" colspan="3">
+ <widget class="QLabel" name="lblStatus">
+ <property name="text">
+ <string/>
+ </property>
+ <property name="alignment">
+ <set>Qt::AlignCenter</set>
+ </property>
+ <property name="wordWrap">
+ <bool>true</bool>
+ </property>
+ </widget>
+ </item>
+ </layout>
+ </widget>
+ <tabstops>
+ <tabstop>cmbProviders</tabstop>
+ <tabstop>lnUser</tabstop>
+ <tabstop>lnPassword</tabstop>
+ <tabstop>chkRemember</tabstop>
+ <tabstop>btnLogin</tabstop>
+ <tabstop>btnCreateAccount</tabstop>
+ </tabstops>
+ <resources/>
+ <connections/>
+</ui>
diff --git a/src/leap/bitmask/gui/ui/mainwindow.ui b/src/leap/bitmask/gui/ui/mainwindow.ui
new file mode 100644
index 00000000..ecd3cbe9
--- /dev/null
+++ b/src/leap/bitmask/gui/ui/mainwindow.ui
@@ -0,0 +1,315 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<ui version="4.0">
+ <class>MainWindow</class>
+ <widget class="QMainWindow" name="MainWindow">
+ <property name="geometry">
+ <rect>
+ <x>0</x>
+ <y>0</y>
+ <width>429</width>
+ <height>579</height>
+ </rect>
+ </property>
+ <property name="windowTitle">
+ <string>Bitmask</string>
+ </property>
+ <property name="windowIcon">
+ <iconset resource="../../../../data/resources/mainwindow.qrc">
+ <normaloff>:/images/mask-icon.png</normaloff>:/images/mask-icon.png</iconset>
+ </property>
+ <property name="inputMethodHints">
+ <set>Qt::ImhHiddenText</set>
+ </property>
+ <property name="iconSize">
+ <size>
+ <width>128</width>
+ <height>128</height>
+ </size>
+ </property>
+ <widget class="QWidget" name="centralwidget">
+ <layout class="QGridLayout" name="gridLayout">
+ <item row="0" column="0" colspan="5">
+ <layout class="QGridLayout" name="gridLayout_4">
+ <item row="2" column="0">
+ <spacer name="horizontalSpacer_8">
+ <property name="orientation">
+ <enum>Qt::Horizontal</enum>
+ </property>
+ <property name="sizeHint" stdset="0">
+ <size>
+ <width>40</width>
+ <height>20</height>
+ </size>
+ </property>
+ </spacer>
+ </item>
+ <item row="1" column="1">
+ <spacer name="horizontalSpacer_7">
+ <property name="orientation">
+ <enum>Qt::Horizontal</enum>
+ </property>
+ <property name="sizeHint" stdset="0">
+ <size>
+ <width>40</width>
+ <height>0</height>
+ </size>
+ </property>
+ </spacer>
+ </item>
+ <item row="2" column="1">
+ <widget class="QLabel" name="lblNewUpdates">
+ <property name="text">
+ <string>There are new updates available, please restart.</string>
+ </property>
+ <property name="alignment">
+ <set>Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter</set>
+ </property>
+ </widget>
+ </item>
+ <item row="2" column="2">
+ <widget class="QPushButton" name="btnMore">
+ <property name="sizePolicy">
+ <sizepolicy hsizetype="Preferred" vsizetype="Fixed">
+ <horstretch>0</horstretch>
+ <verstretch>0</verstretch>
+ </sizepolicy>
+ </property>
+ <property name="text">
+ <string>More...</string>
+ </property>
+ <property name="flat">
+ <bool>true</bool>
+ </property>
+ </widget>
+ </item>
+ <item row="2" column="3">
+ <spacer name="horizontalSpacer_9">
+ <property name="orientation">
+ <enum>Qt::Horizontal</enum>
+ </property>
+ <property name="sizeHint" stdset="0">
+ <size>
+ <width>40</width>
+ <height>20</height>
+ </size>
+ </property>
+ </spacer>
+ </item>
+ </layout>
+ </item>
+ <item row="6" column="2">
+ <spacer name="verticalSpacer">
+ <property name="orientation">
+ <enum>Qt::Vertical</enum>
+ </property>
+ <property name="sizeHint" stdset="0">
+ <size>
+ <width>20</width>
+ <height>40</height>
+ </size>
+ </property>
+ </spacer>
+ </item>
+ <item row="10" column="0" colspan="5">
+ <widget class="QStackedWidget" name="stackedWidget">
+ <property name="currentIndex">
+ <number>1</number>
+ </property>
+ <widget class="QWidget" name="loginPage">
+ <layout class="QGridLayout" name="gridLayout_2">
+ <item row="0" column="1">
+ <layout class="QHBoxLayout" name="loginLayout"/>
+ </item>
+ <item row="0" column="2">
+ <spacer name="horizontalSpacer_4">
+ <property name="orientation">
+ <enum>Qt::Horizontal</enum>
+ </property>
+ <property name="sizeHint" stdset="0">
+ <size>
+ <width>40</width>
+ <height>20</height>
+ </size>
+ </property>
+ </spacer>
+ </item>
+ <item row="0" column="0">
+ <spacer name="horizontalSpacer_3">
+ <property name="orientation">
+ <enum>Qt::Horizontal</enum>
+ </property>
+ <property name="sizeHint" stdset="0">
+ <size>
+ <width>40</width>
+ <height>20</height>
+ </size>
+ </property>
+ </spacer>
+ </item>
+ </layout>
+ </widget>
+ <widget class="QWidget" name="page_2">
+ <layout class="QGridLayout" name="gridLayout_3">
+ <item row="0" column="0">
+ <layout class="QVBoxLayout" name="statusLayout"/>
+ </item>
+ </layout>
+ </widget>
+ </widget>
+ </item>
+ <item row="7" column="2">
+ <widget class="QLabel" name="label">
+ <property name="autoFillBackground">
+ <bool>false</bool>
+ </property>
+ <property name="text">
+ <string/>
+ </property>
+ <property name="pixmap">
+ <pixmap resource="../../../../data/resources/mainwindow.qrc">:/images/mask-launcher.png</pixmap>
+ </property>
+ <property name="alignment">
+ <set>Qt::AlignCenter</set>
+ </property>
+ </widget>
+ </item>
+ <item row="7" column="3" colspan="2">
+ <spacer name="horizontalSpacer_2">
+ <property name="orientation">
+ <enum>Qt::Horizontal</enum>
+ </property>
+ <property name="sizeHint" stdset="0">
+ <size>
+ <width>40</width>
+ <height>20</height>
+ </size>
+ </property>
+ </spacer>
+ </item>
+ <item row="17" column="2">
+ <spacer name="verticalSpacer_2">
+ <property name="orientation">
+ <enum>Qt::Vertical</enum>
+ </property>
+ <property name="sizeHint" stdset="0">
+ <size>
+ <width>20</width>
+ <height>40</height>
+ </size>
+ </property>
+ </spacer>
+ </item>
+ <item row="7" column="0" colspan="2">
+ <spacer name="horizontalSpacer">
+ <property name="orientation">
+ <enum>Qt::Horizontal</enum>
+ </property>
+ <property name="sizeHint" stdset="0">
+ <size>
+ <width>40</width>
+ <height>20</height>
+ </size>
+ </property>
+ </spacer>
+ </item>
+ <item row="18" column="2">
+ <layout class="QHBoxLayout" name="horizontalLayout">
+ <item>
+ <spacer name="horizontalSpacer_10">
+ <property name="orientation">
+ <enum>Qt::Horizontal</enum>
+ </property>
+ <property name="sizeHint" stdset="0">
+ <size>
+ <width>40</width>
+ <height>20</height>
+ </size>
+ </property>
+ </spacer>
+ </item>
+ <item>
+ <widget class="QPushButton" name="btnShowLog">
+ <property name="text">
+ <string>Show Log</string>
+ </property>
+ <property name="checkable">
+ <bool>true</bool>
+ </property>
+ <property name="checked">
+ <bool>false</bool>
+ </property>
+ <property name="flat">
+ <bool>true</bool>
+ </property>
+ </widget>
+ </item>
+ </layout>
+ </item>
+ </layout>
+ </widget>
+ <widget class="QMenuBar" name="menubar">
+ <property name="geometry">
+ <rect>
+ <x>0</x>
+ <y>0</y>
+ <width>429</width>
+ <height>21</height>
+ </rect>
+ </property>
+ <widget class="QMenu" name="menuSession">
+ <property name="title">
+ <string>&amp;Session</string>
+ </property>
+ <addaction name="action_log_out"/>
+ <addaction name="separator"/>
+ <addaction name="action_quit"/>
+ </widget>
+ <widget class="QMenu" name="menuHelp">
+ <property name="title">
+ <string>Help</string>
+ </property>
+ <addaction name="action_help"/>
+ <addaction name="separator"/>
+ <addaction name="action_about_leap"/>
+ </widget>
+ <addaction name="menuSession"/>
+ <addaction name="menuHelp"/>
+ </widget>
+ <widget class="QStatusBar" name="statusbar"/>
+ <action name="action_log_out">
+ <property name="text">
+ <string>Log &amp;out</string>
+ </property>
+ </action>
+ <action name="action_quit">
+ <property name="text">
+ <string>&amp;Quit</string>
+ </property>
+ </action>
+ <action name="action_about_leap">
+ <property name="text">
+ <string>About &amp;Bitmask</string>
+ </property>
+ </action>
+ <action name="action_help">
+ <property name="text">
+ <string>&amp;Help</string>
+ </property>
+ </action>
+ <action name="action_wizard">
+ <property name="text">
+ <string>&amp;Wizard</string>
+ </property>
+ </action>
+ <action name="action_show_logs">
+ <property name="text">
+ <string>Show &amp;logs</string>
+ </property>
+ </action>
+ </widget>
+ <resources>
+ <include location="../../../../data/resources/mainwindow.qrc"/>
+ <include location="../../../../data/resources/locale.qrc"/>
+ </resources>
+ <connections/>
+</ui>
diff --git a/src/leap/bitmask/gui/ui/statuspanel.ui b/src/leap/bitmask/gui/ui/statuspanel.ui
new file mode 100644
index 00000000..3482ac7c
--- /dev/null
+++ b/src/leap/bitmask/gui/ui/statuspanel.ui
@@ -0,0 +1,289 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<ui version="4.0">
+ <class>StatusPanel</class>
+ <widget class="QWidget" name="StatusPanel">
+ <property name="geometry">
+ <rect>
+ <x>0</x>
+ <y>0</y>
+ <width>384</width>
+ <height>477</height>
+ </rect>
+ </property>
+ <property name="windowTitle">
+ <string>Form</string>
+ </property>
+ <layout class="QVBoxLayout" name="verticalLayout">
+ <item>
+ <widget class="QLabel" name="lblProvider">
+ <property name="styleSheet">
+ <string notr="true">font: bold;</string>
+ </property>
+ <property name="text">
+ <string>user@domain.org</string>
+ </property>
+ <property name="wordWrap">
+ <bool>true</bool>
+ </property>
+ </widget>
+ </item>
+ <item>
+ <widget class="QWidget" name="status_rows" native="true">
+ <property name="enabled">
+ <bool>true</bool>
+ </property>
+ <property name="styleSheet">
+ <string notr="true"/>
+ </property>
+ <layout class="QGridLayout" name="gridLayout">
+ <item row="0" column="1">
+ <layout class="QHBoxLayout" name="eip_controls">
+ <item>
+ <widget class="QLabel" name="label">
+ <property name="text">
+ <string>Encrypted Internet: </string>
+ </property>
+ </widget>
+ </item>
+ <item>
+ <widget class="QLabel" name="lblEIPStatus">
+ <property name="styleSheet">
+ <string notr="true">font: bold;</string>
+ </property>
+ <property name="text">
+ <string>Off</string>
+ </property>
+ <property name="textFormat">
+ <enum>Qt::AutoText</enum>
+ </property>
+ <property name="alignment">
+ <set>Qt::AlignCenter</set>
+ </property>
+ <property name="wordWrap">
+ <bool>false</bool>
+ </property>
+ </widget>
+ </item>
+ <item>
+ <spacer name="horizontalSpacer">
+ <property name="orientation">
+ <enum>Qt::Horizontal</enum>
+ </property>
+ <property name="sizeHint" stdset="0">
+ <size>
+ <width>40</width>
+ <height>20</height>
+ </size>
+ </property>
+ </spacer>
+ </item>
+ <item>
+ <widget class="QPushButton" name="btnEipStartStop">
+ <property name="text">
+ <string>Turn On</string>
+ </property>
+ </widget>
+ </item>
+ </layout>
+ </item>
+ <item row="2" column="0">
+ <spacer name="verticalSpacer_2">
+ <property name="orientation">
+ <enum>Qt::Vertical</enum>
+ </property>
+ <property name="sizeType">
+ <enum>QSizePolicy::Preferred</enum>
+ </property>
+ <property name="sizeHint" stdset="0">
+ <size>
+ <width>0</width>
+ <height>11</height>
+ </size>
+ </property>
+ </spacer>
+ </item>
+ <item row="0" column="0" rowspan="2">
+ <widget class="QLabel" name="lblVPNStatusIcon">
+ <property name="maximumSize">
+ <size>
+ <width>64</width>
+ <height>64</height>
+ </size>
+ </property>
+ <property name="text">
+ <string/>
+ </property>
+ <property name="pixmap">
+ <pixmap resource="../../../../data/resources/icons.qrc">:/images/light/64/network-eip-down.png</pixmap>
+ </property>
+ <property name="alignment">
+ <set>Qt::AlignCenter</set>
+ </property>
+ </widget>
+ </item>
+ <item row="1" column="1">
+ <layout class="QHBoxLayout" name="eip_bandwidth">
+ <property name="spacing">
+ <number>4</number>
+ </property>
+ <property name="sizeConstraint">
+ <enum>QLayout::SetDefaultConstraint</enum>
+ </property>
+ <item>
+ <widget class="QLabel" name="label_5">
+ <property name="text">
+ <string/>
+ </property>
+ <property name="pixmap">
+ <pixmap resource="../../../../data/resources/icons.qrc">:/images/light/16/down-arrow.png</pixmap>
+ </property>
+ </widget>
+ </item>
+ <item>
+ <widget class="QPushButton" name="btnDownload">
+ <property name="sizePolicy">
+ <sizepolicy hsizetype="Fixed" vsizetype="Fixed">
+ <horstretch>0</horstretch>
+ <verstretch>0</verstretch>
+ </sizepolicy>
+ </property>
+ <property name="minimumSize">
+ <size>
+ <width>100</width>
+ <height>0</height>
+ </size>
+ </property>
+ <property name="maximumSize">
+ <size>
+ <width>120</width>
+ <height>16777215</height>
+ </size>
+ </property>
+ <property name="cursor">
+ <cursorShape>PointingHandCursor</cursorShape>
+ </property>
+ <property name="text">
+ <string>0.0 KB/s</string>
+ </property>
+ <property name="flat">
+ <bool>true</bool>
+ </property>
+ </widget>
+ </item>
+ <item>
+ <spacer name="horizontalSpacer_3">
+ <property name="orientation">
+ <enum>Qt::Horizontal</enum>
+ </property>
+ <property name="sizeType">
+ <enum>QSizePolicy::Fixed</enum>
+ </property>
+ <property name="sizeHint" stdset="0">
+ <size>
+ <width>20</width>
+ <height>20</height>
+ </size>
+ </property>
+ </spacer>
+ </item>
+ <item>
+ <widget class="QLabel" name="label_7">
+ <property name="text">
+ <string/>
+ </property>
+ <property name="pixmap">
+ <pixmap resource="../../../../data/resources/icons.qrc">:/images/light/16/up-arrow.png</pixmap>
+ </property>
+ </widget>
+ </item>
+ <item alignment="Qt::AlignLeft">
+ <widget class="QPushButton" name="btnUpload">
+ <property name="sizePolicy">
+ <sizepolicy hsizetype="Fixed" vsizetype="Fixed">
+ <horstretch>0</horstretch>
+ <verstretch>0</verstretch>
+ </sizepolicy>
+ </property>
+ <property name="minimumSize">
+ <size>
+ <width>100</width>
+ <height>0</height>
+ </size>
+ </property>
+ <property name="maximumSize">
+ <size>
+ <width>120</width>
+ <height>16777215</height>
+ </size>
+ </property>
+ <property name="cursor">
+ <cursorShape>PointingHandCursor</cursorShape>
+ </property>
+ <property name="text">
+ <string>0.0 KB/s</string>
+ </property>
+ <property name="flat">
+ <bool>true</bool>
+ </property>
+ </widget>
+ </item>
+ <item>
+ <spacer name="horizontalSpacer_2">
+ <property name="orientation">
+ <enum>Qt::Horizontal</enum>
+ </property>
+ <property name="sizeHint" stdset="0">
+ <size>
+ <width>40</width>
+ <height>20</height>
+ </size>
+ </property>
+ </spacer>
+ </item>
+ </layout>
+ </item>
+ <item row="3" column="0" colspan="2">
+ <widget class="QGroupBox" name="globalStatusBox">
+ <property name="enabled">
+ <bool>false</bool>
+ </property>
+ <layout class="QGridLayout" name="gridLayout_2">
+ <item row="0" column="0">
+ <widget class="QLabel" name="lblGlobalStatus">
+ <property name="enabled">
+ <bool>false</bool>
+ </property>
+ <property name="text">
+ <string>...</string>
+ </property>
+ <property name="wordWrap">
+ <bool>true</bool>
+ </property>
+ </widget>
+ </item>
+ </layout>
+ </widget>
+ </item>
+ </layout>
+ </widget>
+ </item>
+ <item>
+ <spacer name="verticalSpacer">
+ <property name="orientation">
+ <enum>Qt::Vertical</enum>
+ </property>
+ <property name="sizeHint" stdset="0">
+ <size>
+ <width>20</width>
+ <height>40</height>
+ </size>
+ </property>
+ </spacer>
+ </item>
+ </layout>
+ </widget>
+ <resources>
+ <include location="../../../../data/resources/icons.qrc"/>
+ </resources>
+ <connections/>
+</ui>
diff --git a/src/leap/bitmask/gui/ui/wizard.ui b/src/leap/bitmask/gui/ui/wizard.ui
new file mode 100644
index 00000000..5e0108dc
--- /dev/null
+++ b/src/leap/bitmask/gui/ui/wizard.ui
@@ -0,0 +1,846 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<ui version="4.0">
+ <class>Wizard</class>
+ <widget class="QWizard" name="Wizard">
+ <property name="geometry">
+ <rect>
+ <x>0</x>
+ <y>0</y>
+ <width>536</width>
+ <height>452</height>
+ </rect>
+ </property>
+ <property name="windowTitle">
+ <string>Bitmask first run</string>
+ </property>
+ <property name="windowIcon">
+ <iconset resource="../../../../data/resources/mainwindow.qrc">
+ <normaloff>:/images/mask-icon.png</normaloff>:/images/mask-icon.png</iconset>
+ </property>
+ <property name="modal">
+ <bool>true</bool>
+ </property>
+ <property name="wizardStyle">
+ <enum>QWizard::ModernStyle</enum>
+ </property>
+ <property name="options">
+ <set>QWizard::IndependentPages</set>
+ </property>
+ <widget class="QWizardPage" name="introduction_page">
+ <property name="title">
+ <string>Welcome</string>
+ </property>
+ <property name="subTitle">
+ <string>This is the Bitmask first run wizard</string>
+ </property>
+ <attribute name="pageId">
+ <string notr="true">0</string>
+ </attribute>
+ <layout class="QGridLayout" name="gridLayout">
+ <item row="3" column="0">
+ <widget class="QRadioButton" name="rdoLogin">
+ <property name="text">
+ <string>Log In with my credentials</string>
+ </property>
+ </widget>
+ </item>
+ <item row="0" column="0">
+ <widget class="QLabel" name="label_3">
+ <property name="text">
+ <string>&lt;html&gt;&lt;head/&gt;&lt;body&gt;&lt;p&gt;Now we will guide you through some configuration that is needed before you can connect for the first time.&lt;/p&gt;&lt;p&gt;If you ever need to modify these options again, you can find the wizard in the &lt;span style=&quot; font-style:italic;&quot;&gt;'Settings'&lt;/span&gt; menu from the main window.&lt;/p&gt;&lt;p&gt;Do you want to &lt;span style=&quot; font-weight:600;&quot;&gt;sign up&lt;/span&gt; for a new account, or &lt;span style=&quot; font-weight:600;&quot;&gt;log in&lt;/span&gt; with an already existing username?&lt;/p&gt;&lt;/body&gt;&lt;/html&gt;</string>
+ </property>
+ <property name="textFormat">
+ <enum>Qt::RichText</enum>
+ </property>
+ <property name="wordWrap">
+ <bool>true</bool>
+ </property>
+ </widget>
+ </item>
+ <item row="2" column="0">
+ <widget class="QRadioButton" name="rdoRegister">
+ <property name="text">
+ <string>Sign up for a new account</string>
+ </property>
+ <property name="checked">
+ <bool>true</bool>
+ </property>
+ </widget>
+ </item>
+ <item row="1" column="0">
+ <spacer name="verticalSpacer_11">
+ <property name="orientation">
+ <enum>Qt::Vertical</enum>
+ </property>
+ <property name="sizeHint" stdset="0">
+ <size>
+ <width>20</width>
+ <height>40</height>
+ </size>
+ </property>
+ </spacer>
+ </item>
+ <item row="4" column="0">
+ <spacer name="verticalSpacer_12">
+ <property name="orientation">
+ <enum>Qt::Vertical</enum>
+ </property>
+ <property name="sizeHint" stdset="0">
+ <size>
+ <width>20</width>
+ <height>40</height>
+ </size>
+ </property>
+ </spacer>
+ </item>
+ </layout>
+ </widget>
+ <widget class="WizardPage" name="select_provider_page">
+ <property name="title">
+ <string>Provider selection</string>
+ </property>
+ <property name="subTitle">
+ <string>Please enter the domain of the provider you want to use for your connection</string>
+ </property>
+ <attribute name="pageId">
+ <string notr="true">1</string>
+ </attribute>
+ <layout class="QGridLayout" name="gridLayout_2">
+ <item row="0" column="1">
+ <spacer name="verticalSpacer">
+ <property name="orientation">
+ <enum>Qt::Vertical</enum>
+ </property>
+ <property name="sizeType">
+ <enum>QSizePolicy::Fixed</enum>
+ </property>
+ <property name="sizeHint" stdset="0">
+ <size>
+ <width>20</width>
+ <height>60</height>
+ </size>
+ </property>
+ </spacer>
+ </item>
+ <item row="1" column="1">
+ <widget class="QLineEdit" name="lnProvider"/>
+ </item>
+ <item row="1" column="2">
+ <widget class="QPushButton" name="btnCheck">
+ <property name="text">
+ <string>Check</string>
+ </property>
+ </widget>
+ </item>
+ <item row="3" column="1">
+ <spacer name="verticalSpacer_2">
+ <property name="orientation">
+ <enum>Qt::Vertical</enum>
+ </property>
+ <property name="sizeHint" stdset="0">
+ <size>
+ <width>20</width>
+ <height>40</height>
+ </size>
+ </property>
+ </spacer>
+ </item>
+ <item row="1" column="0">
+ <widget class="QLabel" name="label">
+ <property name="text">
+ <string>https://</string>
+ </property>
+ <property name="alignment">
+ <set>Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter</set>
+ </property>
+ </widget>
+ </item>
+ <item row="4" column="0" colspan="3">
+ <widget class="QGroupBox" name="grpCheckProvider">
+ <property name="title">
+ <string>Checking for a valid provider</string>
+ </property>
+ <layout class="QGridLayout" name="gridLayout_3">
+ <item row="3" column="0">
+ <widget class="QLabel" name="label_5">
+ <property name="text">
+ <string>Getting provider information</string>
+ </property>
+ </widget>
+ </item>
+ <item row="2" column="0">
+ <widget class="QLabel" name="label_4">
+ <property name="text">
+ <string>Can we stablish a secure connection?</string>
+ </property>
+ </widget>
+ </item>
+ <item row="3" column="1">
+ <widget class="QLabel" name="lblProviderInfo">
+ <property name="sizePolicy">
+ <sizepolicy hsizetype="Fixed" vsizetype="Fixed">
+ <horstretch>0</horstretch>
+ <verstretch>0</verstretch>
+ </sizepolicy>
+ </property>
+ <property name="minimumSize">
+ <size>
+ <width>24</width>
+ <height>24</height>
+ </size>
+ </property>
+ <property name="text">
+ <string/>
+ </property>
+ <property name="pixmap">
+ <pixmap resource="../../../../data/resources/mainwindow.qrc">:/images/Emblem-question.png</pixmap>
+ </property>
+ </widget>
+ </item>
+ <item row="2" column="1">
+ <widget class="QLabel" name="lblHTTPS">
+ <property name="sizePolicy">
+ <sizepolicy hsizetype="Fixed" vsizetype="Fixed">
+ <horstretch>0</horstretch>
+ <verstretch>0</verstretch>
+ </sizepolicy>
+ </property>
+ <property name="minimumSize">
+ <size>
+ <width>24</width>
+ <height>24</height>
+ </size>
+ </property>
+ <property name="text">
+ <string/>
+ </property>
+ <property name="pixmap">
+ <pixmap resource="../../../../data/resources/mainwindow.qrc">:/images/Emblem-question.png</pixmap>
+ </property>
+ </widget>
+ </item>
+ <item row="1" column="1">
+ <widget class="QLabel" name="lblNameResolution">
+ <property name="sizePolicy">
+ <sizepolicy hsizetype="Fixed" vsizetype="Fixed">
+ <horstretch>0</horstretch>
+ <verstretch>0</verstretch>
+ </sizepolicy>
+ </property>
+ <property name="minimumSize">
+ <size>
+ <width>24</width>
+ <height>24</height>
+ </size>
+ </property>
+ <property name="text">
+ <string/>
+ </property>
+ <property name="pixmap">
+ <pixmap resource="../../../../data/resources/mainwindow.qrc">:/images/Emblem-question.png</pixmap>
+ </property>
+ </widget>
+ </item>
+ <item row="1" column="0">
+ <widget class="QLabel" name="label_2">
+ <property name="text">
+ <string>Can we reach this provider?</string>
+ </property>
+ </widget>
+ </item>
+ <item row="0" column="0">
+ <spacer name="horizontalSpacer">
+ <property name="orientation">
+ <enum>Qt::Horizontal</enum>
+ </property>
+ <property name="sizeHint" stdset="0">
+ <size>
+ <width>40</width>
+ <height>0</height>
+ </size>
+ </property>
+ </spacer>
+ </item>
+ </layout>
+ </widget>
+ </item>
+ <item row="2" column="1" colspan="2">
+ <widget class="QLabel" name="lblProviderSelectStatus">
+ <property name="text">
+ <string/>
+ </property>
+ </widget>
+ </item>
+ </layout>
+ </widget>
+ <widget class="QWizardPage" name="provider_info_page">
+ <property name="title">
+ <string>Provider Information</string>
+ </property>
+ <property name="subTitle">
+ <string>Description of services offered by this provider</string>
+ </property>
+ <attribute name="pageId">
+ <string notr="true">2</string>
+ </attribute>
+ <layout class="QGridLayout" name="gridLayout_4">
+ <item row="1" column="0" colspan="2">
+ <widget class="QLabel" name="lblProviderName">
+ <property name="text">
+ <string>Name</string>
+ </property>
+ </widget>
+ </item>
+ <item row="6" column="1">
+ <spacer name="verticalSpacer_15">
+ <property name="orientation">
+ <enum>Qt::Vertical</enum>
+ </property>
+ <property name="sizeHint" stdset="0">
+ <size>
+ <width>20</width>
+ <height>40</height>
+ </size>
+ </property>
+ </spacer>
+ </item>
+ <item row="3" column="1" colspan="2">
+ <widget class="QLabel" name="lblProviderDesc">
+ <property name="minimumSize">
+ <size>
+ <width>0</width>
+ <height>0</height>
+ </size>
+ </property>
+ <property name="baseSize">
+ <size>
+ <width>200</width>
+ <height>0</height>
+ </size>
+ </property>
+ <property name="text">
+ <string>Desc</string>
+ </property>
+ <property name="wordWrap">
+ <bool>true</bool>
+ </property>
+ </widget>
+ </item>
+ <item row="4" column="0">
+ <widget class="QLabel" name="lblServ">
+ <property name="text">
+ <string>&lt;b&gt;Services offered:&lt;/b&gt;</string>
+ </property>
+ <property name="alignment">
+ <set>Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter</set>
+ </property>
+ </widget>
+ </item>
+ <item row="4" column="1">
+ <widget class="QLabel" name="lblServicesOffered">
+ <property name="text">
+ <string>services</string>
+ </property>
+ </widget>
+ </item>
+ <item row="4" column="2">
+ <spacer name="horizontalSpacer_6">
+ <property name="orientation">
+ <enum>Qt::Horizontal</enum>
+ </property>
+ <property name="sizeHint" stdset="0">
+ <size>
+ <width>40</width>
+ <height>20</height>
+ </size>
+ </property>
+ </spacer>
+ </item>
+ <item row="5" column="0">
+ <widget class="QLabel" name="label_12">
+ <property name="text">
+ <string>&lt;b&gt;Enrollment policy:&lt;/b&gt;</string>
+ </property>
+ <property name="alignment">
+ <set>Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter</set>
+ </property>
+ </widget>
+ </item>
+ <item row="5" column="1">
+ <widget class="QLabel" name="lblProviderPolicy">
+ <property name="text">
+ <string>policy</string>
+ </property>
+ </widget>
+ </item>
+ <item row="0" column="1">
+ <spacer name="verticalSpacer_5">
+ <property name="orientation">
+ <enum>Qt::Vertical</enum>
+ </property>
+ <property name="sizeHint" stdset="0">
+ <size>
+ <width>20</width>
+ <height>40</height>
+ </size>
+ </property>
+ </spacer>
+ </item>
+ <item row="2" column="0">
+ <widget class="QLabel" name="label_6">
+ <property name="text">
+ <string>&lt;b&gt;URL:&lt;/b&gt;</string>
+ </property>
+ <property name="alignment">
+ <set>Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter</set>
+ </property>
+ </widget>
+ </item>
+ <item row="2" column="1" colspan="2">
+ <widget class="QLabel" name="lblProviderURL">
+ <property name="text">
+ <string>URL</string>
+ </property>
+ </widget>
+ </item>
+ <item row="3" column="0">
+ <widget class="QLabel" name="label_7">
+ <property name="text">
+ <string>&lt;b&gt;Description:&lt;/b&gt;</string>
+ </property>
+ <property name="alignment">
+ <set>Qt::AlignRight|Qt::AlignTop|Qt::AlignTrailing</set>
+ </property>
+ </widget>
+ </item>
+ </layout>
+ </widget>
+ <widget class="WizardPage" name="setup_provider_page">
+ <property name="title">
+ <string>Provider setup</string>
+ </property>
+ <property name="subTitle">
+ <string>Gathering configuration options for this provider</string>
+ </property>
+ <attribute name="pageId">
+ <string notr="true">3</string>
+ </attribute>
+ <layout class="QVBoxLayout" name="verticalLayout">
+ <item>
+ <spacer name="verticalSpacer_3">
+ <property name="orientation">
+ <enum>Qt::Vertical</enum>
+ </property>
+ <property name="sizeHint" stdset="0">
+ <size>
+ <width>20</width>
+ <height>60</height>
+ </size>
+ </property>
+ </spacer>
+ </item>
+ <item>
+ <widget class="QLabel" name="lblSetupProviderExpl">
+ <property name="text">
+ <string>We are downloading some bits that we need to establish a secure connection with the provider for the first time.</string>
+ </property>
+ <property name="wordWrap">
+ <bool>true</bool>
+ </property>
+ </widget>
+ </item>
+ <item>
+ <spacer name="verticalSpacer_6">
+ <property name="orientation">
+ <enum>Qt::Vertical</enum>
+ </property>
+ <property name="sizeHint" stdset="0">
+ <size>
+ <width>20</width>
+ <height>40</height>
+ </size>
+ </property>
+ </spacer>
+ </item>
+ <item>
+ <widget class="QGroupBox" name="groupBox_2">
+ <property name="title">
+ <string>Setting up provider</string>
+ </property>
+ <layout class="QGridLayout" name="gridLayout_6">
+ <item row="2" column="1">
+ <widget class="QLabel" name="lblCheckCaFpr">
+ <property name="sizePolicy">
+ <sizepolicy hsizetype="Fixed" vsizetype="Fixed">
+ <horstretch>0</horstretch>
+ <verstretch>0</verstretch>
+ </sizepolicy>
+ </property>
+ <property name="minimumSize">
+ <size>
+ <width>24</width>
+ <height>24</height>
+ </size>
+ </property>
+ <property name="text">
+ <string/>
+ </property>
+ <property name="pixmap">
+ <pixmap resource="../../../../data/resources/mainwindow.qrc">:/images/Emblem-question.png</pixmap>
+ </property>
+ </widget>
+ </item>
+ <item row="1" column="1">
+ <widget class="QLabel" name="lblDownloadCaCert">
+ <property name="sizePolicy">
+ <sizepolicy hsizetype="Fixed" vsizetype="Fixed">
+ <horstretch>0</horstretch>
+ <verstretch>0</verstretch>
+ </sizepolicy>
+ </property>
+ <property name="minimumSize">
+ <size>
+ <width>24</width>
+ <height>24</height>
+ </size>
+ </property>
+ <property name="text">
+ <string/>
+ </property>
+ <property name="pixmap">
+ <pixmap resource="../../../../data/resources/mainwindow.qrc">:/images/Emblem-question.png</pixmap>
+ </property>
+ </widget>
+ </item>
+ <item row="1" column="0">
+ <widget class="QLabel" name="label_9">
+ <property name="text">
+ <string>Getting info from the Certificate Authority</string>
+ </property>
+ </widget>
+ </item>
+ <item row="2" column="0">
+ <widget class="QLabel" name="label_10">
+ <property name="text">
+ <string>Do we trust this Certificate Authority?</string>
+ </property>
+ </widget>
+ </item>
+ <item row="3" column="0">
+ <widget class="QLabel" name="label_11">
+ <property name="text">
+ <string>Establishing a trust relationship with this provider</string>
+ </property>
+ </widget>
+ </item>
+ <item row="3" column="1">
+ <widget class="QLabel" name="lblCheckApiCert">
+ <property name="sizePolicy">
+ <sizepolicy hsizetype="Fixed" vsizetype="Fixed">
+ <horstretch>0</horstretch>
+ <verstretch>0</verstretch>
+ </sizepolicy>
+ </property>
+ <property name="minimumSize">
+ <size>
+ <width>24</width>
+ <height>24</height>
+ </size>
+ </property>
+ <property name="text">
+ <string/>
+ </property>
+ <property name="pixmap">
+ <pixmap resource="../../../../data/resources/mainwindow.qrc">:/images/Emblem-question.png</pixmap>
+ </property>
+ </widget>
+ </item>
+ <item row="0" column="0">
+ <spacer name="horizontalSpacer_2">
+ <property name="orientation">
+ <enum>Qt::Horizontal</enum>
+ </property>
+ <property name="sizeHint" stdset="0">
+ <size>
+ <width>40</width>
+ <height>0</height>
+ </size>
+ </property>
+ </spacer>
+ </item>
+ </layout>
+ </widget>
+ </item>
+ <item>
+ <spacer name="verticalSpacer_8">
+ <property name="orientation">
+ <enum>Qt::Vertical</enum>
+ </property>
+ <property name="sizeHint" stdset="0">
+ <size>
+ <width>20</width>
+ <height>40</height>
+ </size>
+ </property>
+ </spacer>
+ </item>
+ </layout>
+ </widget>
+ <widget class="WizardPage" name="register_user_page">
+ <property name="title">
+ <string>Register new user</string>
+ </property>
+ <property name="subTitle">
+ <string>Register a new user with provider</string>
+ </property>
+ <attribute name="pageId">
+ <string notr="true">4</string>
+ </attribute>
+ <layout class="QGridLayout" name="gridLayout_7">
+ <property name="sizeConstraint">
+ <enum>QLayout::SetDefaultConstraint</enum>
+ </property>
+ <property name="leftMargin">
+ <number>4</number>
+ </property>
+ <item row="3" column="0">
+ <widget class="QLabel" name="label_16">
+ <property name="text">
+ <string>&lt;b&gt;Password:&lt;/b&gt;</string>
+ </property>
+ <property name="alignment">
+ <set>Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter</set>
+ </property>
+ </widget>
+ </item>
+ <item row="3" column="1" colspan="2">
+ <widget class="QLineEdit" name="lblPassword"/>
+ </item>
+ <item row="4" column="1" colspan="2">
+ <widget class="QLineEdit" name="lblPassword2"/>
+ </item>
+ <item row="2" column="1" colspan="2">
+ <widget class="QLineEdit" name="lblUser"/>
+ </item>
+ <item row="4" column="0">
+ <widget class="QLabel" name="label_17">
+ <property name="text">
+ <string>&lt;b&gt;Re-enter password:&lt;/b&gt;</string>
+ </property>
+ <property name="alignment">
+ <set>Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter</set>
+ </property>
+ </widget>
+ </item>
+ <item row="6" column="1">
+ <widget class="QPushButton" name="btnRegister">
+ <property name="text">
+ <string>Register</string>
+ </property>
+ </widget>
+ </item>
+ <item row="0" column="1">
+ <spacer name="verticalSpacer_4">
+ <property name="orientation">
+ <enum>Qt::Vertical</enum>
+ </property>
+ <property name="sizeHint" stdset="0">
+ <size>
+ <width>20</width>
+ <height>60</height>
+ </size>
+ </property>
+ </spacer>
+ </item>
+ <item row="7" column="1">
+ <spacer name="verticalSpacer_7">
+ <property name="orientation">
+ <enum>Qt::Vertical</enum>
+ </property>
+ <property name="sizeHint" stdset="0">
+ <size>
+ <width>20</width>
+ <height>40</height>
+ </size>
+ </property>
+ </spacer>
+ </item>
+ <item row="2" column="0">
+ <widget class="QLabel" name="label_15">
+ <property name="text">
+ <string>&lt;b&gt;Username:&lt;/b&gt;</string>
+ </property>
+ <property name="alignment">
+ <set>Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter</set>
+ </property>
+ </widget>
+ </item>
+ <item row="5" column="1" colspan="2">
+ <widget class="QCheckBox" name="chkRemember">
+ <property name="enabled">
+ <bool>false</bool>
+ </property>
+ <property name="text">
+ <string>Remember my username and password</string>
+ </property>
+ </widget>
+ </item>
+ <item row="1" column="1" colspan="2">
+ <widget class="QLabel" name="lblRegisterStatus">
+ <property name="text">
+ <string/>
+ </property>
+ <property name="textFormat">
+ <enum>Qt::AutoText</enum>
+ </property>
+ <property name="alignment">
+ <set>Qt::AlignLeading|Qt::AlignLeft|Qt::AlignVCenter</set>
+ </property>
+ <property name="wordWrap">
+ <bool>true</bool>
+ </property>
+ </widget>
+ </item>
+ </layout>
+ </widget>
+ <widget class="QWizardPage" name="service_selection">
+ <property name="title">
+ <string>Service selection</string>
+ </property>
+ <property name="subTitle">
+ <string>Please select the services you would like to have</string>
+ </property>
+ <attribute name="pageId">
+ <string notr="true">5</string>
+ </attribute>
+ <layout class="QGridLayout" name="gridLayout_8">
+ <item row="0" column="0">
+ <widget class="QGroupBox" name="grpServices">
+ <property name="title">
+ <string notr="true">Services by PROVIDER</string>
+ </property>
+ <layout class="QGridLayout" name="gridLayout_9">
+ <item row="0" column="0">
+ <layout class="QVBoxLayout" name="serviceListLayout"/>
+ </item>
+ </layout>
+ </widget>
+ </item>
+ </layout>
+ </widget>
+ <widget class="QWizardPage" name="finish_page">
+ <property name="title">
+ <string>Congratulations!</string>
+ </property>
+ <property name="subTitle">
+ <string>You have successfully configured Bitmask.</string>
+ </property>
+ <attribute name="pageId">
+ <string notr="true">6</string>
+ </attribute>
+ <layout class="QGridLayout" name="gridLayout_10">
+ <item row="1" column="0">
+ <spacer name="horizontalSpacer_4">
+ <property name="orientation">
+ <enum>Qt::Horizontal</enum>
+ </property>
+ <property name="sizeHint" stdset="0">
+ <size>
+ <width>40</width>
+ <height>20</height>
+ </size>
+ </property>
+ </spacer>
+ </item>
+ <item row="0" column="1">
+ <spacer name="verticalSpacer_9">
+ <property name="orientation">
+ <enum>Qt::Vertical</enum>
+ </property>
+ <property name="sizeHint" stdset="0">
+ <size>
+ <width>20</width>
+ <height>40</height>
+ </size>
+ </property>
+ </spacer>
+ </item>
+ <item row="1" column="1">
+ <widget class="QLabel" name="label_23">
+ <property name="text">
+ <string/>
+ </property>
+ <property name="pixmap">
+ <pixmap resource="../../../../data/resources/mainwindow.qrc">:/images/mask-icon.png</pixmap>
+ </property>
+ </widget>
+ </item>
+ <item row="1" column="2">
+ <widget class="QLabel" name="label_25">
+ <property name="sizePolicy">
+ <sizepolicy hsizetype="Fixed" vsizetype="Fixed">
+ <horstretch>0</horstretch>
+ <verstretch>0</verstretch>
+ </sizepolicy>
+ </property>
+ <property name="text">
+ <string/>
+ </property>
+ <property name="pixmap">
+ <pixmap resource="../../../../data/resources/mainwindow.qrc">:/images/Globe.png</pixmap>
+ </property>
+ </widget>
+ </item>
+ <item row="3" column="1">
+ <spacer name="verticalSpacer_10">
+ <property name="orientation">
+ <enum>Qt::Vertical</enum>
+ </property>
+ <property name="sizeHint" stdset="0">
+ <size>
+ <width>20</width>
+ <height>40</height>
+ </size>
+ </property>
+ </spacer>
+ </item>
+ <item row="1" column="3">
+ <spacer name="horizontalSpacer_5">
+ <property name="orientation">
+ <enum>Qt::Horizontal</enum>
+ </property>
+ <property name="sizeHint" stdset="0">
+ <size>
+ <width>40</width>
+ <height>20</height>
+ </size>
+ </property>
+ </spacer>
+ </item>
+ </layout>
+ </widget>
+ </widget>
+ <customwidgets>
+ <customwidget>
+ <class>WizardPage</class>
+ <extends>QWizardPage</extends>
+ <header>wizardpage.h</header>
+ <container>1</container>
+ </customwidget>
+ </customwidgets>
+ <tabstops>
+ <tabstop>lblUser</tabstop>
+ <tabstop>lblPassword</tabstop>
+ <tabstop>lblPassword2</tabstop>
+ <tabstop>btnRegister</tabstop>
+ <tabstop>rdoRegister</tabstop>
+ <tabstop>rdoLogin</tabstop>
+ <tabstop>lnProvider</tabstop>
+ <tabstop>btnCheck</tabstop>
+ </tabstops>
+ <resources>
+ <include location="../../../../data/resources/mainwindow.qrc"/>
+ <include location="../../../../data/resources/locale.qrc"/>
+ </resources>
+ <connections/>
+</ui>
diff --git a/src/leap/bitmask/gui/wizard.py b/src/leap/bitmask/gui/wizard.py
new file mode 100644
index 00000000..ed6c1da0
--- /dev/null
+++ b/src/leap/bitmask/gui/wizard.py
@@ -0,0 +1,628 @@
+# -*- coding: utf-8 -*-
+# wizard.py
+# Copyright (C) 2013 LEAP
+#
+# 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/>.
+
+"""
+First run wizard
+"""
+import os
+import logging
+import json
+
+from functools import partial
+
+from PySide import QtCore, QtGui
+from twisted.internet import threads
+
+from leap.bitmask.config.providerconfig import ProviderConfig
+from leap.bitmask.crypto.srpregister import SRPRegister
+from leap.bitmask.util.privilege_policies import is_missing_policy_permissions
+from leap.bitmask.util.request_helpers import get_content
+from leap.bitmask.util.keyring_helpers import has_keyring
+from leap.bitmask.services.eip.providerbootstrapper import ProviderBootstrapper
+from leap.bitmask.services import get_supported
+
+from ui_wizard import Ui_Wizard
+
+logger = logging.getLogger(__name__)
+
+
+class Wizard(QtGui.QWizard):
+ """
+ First run wizard to register a user and setup a provider
+ """
+
+ INTRO_PAGE = 0
+ SELECT_PROVIDER_PAGE = 1
+ PRESENT_PROVIDER_PAGE = 2
+ SETUP_PROVIDER_PAGE = 3
+ REGISTER_USER_PAGE = 4
+ SERVICES_PAGE = 5
+ FINISH_PAGE = 6
+
+ WEAK_PASSWORDS = ("123456", "qweasd", "qwerty",
+ "password")
+
+ BARE_USERNAME_REGEX = r"^[A-Za-z\d_]+$"
+
+ def __init__(self, standalone=False, bypass_checks=False):
+ """
+ Constructor for the main Wizard.
+
+ :param standalone: If True, the application is running as standalone
+ and the wizard should display some messages according to this.
+ :type standalone: bool
+ :param bypass_checks: Set to true if the app should bypass
+ first round of checks for CA certificates at bootstrap
+ :type bypass_checks: bool
+ """
+ QtGui.QWizard.__init__(self)
+
+ self.standalone = standalone
+
+ self.ui = Ui_Wizard()
+ self.ui.setupUi(self)
+
+ self.setPixmap(QtGui.QWizard.LogoPixmap,
+ QtGui.QPixmap(":/images/mask-icon.png"))
+
+ self.QUESTION_ICON = QtGui.QPixmap(":/images/Emblem-question.png")
+ self.ERROR_ICON = QtGui.QPixmap(":/images/Dialog-error.png")
+ self.OK_ICON = QtGui.QPixmap(":/images/Dialog-accept.png")
+
+ # Correspondence for services and their name to display
+ EIP_LABEL = self.tr("Encrypted Internet")
+ MX_LABEL = self.tr("Encrypted Mail")
+
+ if self._is_need_eip_password_warning():
+ EIP_LABEL += " " + self.tr(
+ "(will need admin password to start)")
+
+ self.SERVICE_DISPLAY = [
+ EIP_LABEL,
+ MX_LABEL
+ ]
+ self.SERVICE_CONFIG = [
+ "openvpn",
+ "mx"
+ ]
+
+ self._selected_services = set()
+ self._shown_services = set()
+
+ self._show_register = False
+
+ self.ui.grpCheckProvider.setVisible(False)
+ self.ui.btnCheck.clicked.connect(self._check_provider)
+ self.ui.lnProvider.returnPressed.connect(self._check_provider)
+
+ self._provider_bootstrapper = ProviderBootstrapper(bypass_checks)
+ self._provider_bootstrapper.name_resolution.connect(
+ self._name_resolution)
+ self._provider_bootstrapper.https_connection.connect(
+ self._https_connection)
+ self._provider_bootstrapper.download_provider_info.connect(
+ self._download_provider_info)
+
+ self._provider_bootstrapper.download_ca_cert.connect(
+ self._download_ca_cert)
+ self._provider_bootstrapper.check_ca_fingerprint.connect(
+ self._check_ca_fingerprint)
+ self._provider_bootstrapper.check_api_certificate.connect(
+ self._check_api_certificate)
+
+ self._domain = None
+ self._provider_config = ProviderConfig()
+
+ # We will store a reference to the defers for eventual use
+ # (eg, to cancel them) but not doing anything with them right now.
+ self._provider_select_defer = None
+ self._provider_setup_defer = None
+
+ self.currentIdChanged.connect(self._current_id_changed)
+
+ self.ui.lblPassword.setEchoMode(QtGui.QLineEdit.Password)
+ self.ui.lblPassword2.setEchoMode(QtGui.QLineEdit.Password)
+
+ self.ui.lnProvider.textChanged.connect(
+ self._enable_check)
+
+ self.ui.lblUser.returnPressed.connect(
+ self._focus_password)
+ self.ui.lblPassword.returnPressed.connect(
+ self._focus_second_password)
+ self.ui.lblPassword2.returnPressed.connect(
+ self._register)
+ self.ui.btnRegister.clicked.connect(
+ self._register)
+
+ usernameRe = QtCore.QRegExp(self.BARE_USERNAME_REGEX)
+ self.ui.lblUser.setValidator(
+ QtGui.QRegExpValidator(usernameRe, self))
+
+ self.page(self.REGISTER_USER_PAGE).setCommitPage(True)
+
+ self._username = None
+ self._password = None
+
+ self.page(self.REGISTER_USER_PAGE).setButtonText(
+ QtGui.QWizard.CommitButton, self.tr("&Next >"))
+ self.page(self.FINISH_PAGE).setButtonText(
+ QtGui.QWizard.FinishButton, self.tr("Connect"))
+
+ # XXX: Temporary removal for enrollment policy
+ # https://leap.se/code/issues/2922
+ self.ui.label_12.setVisible(False)
+ self.ui.lblProviderPolicy.setVisible(False)
+
+ def get_domain(self):
+ return self._domain
+
+ def get_username(self):
+ return self._username
+
+ def get_password(self):
+ return self._password
+
+ def get_remember(self):
+ return has_keyring() and self.ui.chkRemember.isChecked()
+
+ def get_services(self):
+ return self._selected_services
+
+ def _enable_check(self, text):
+ self.ui.btnCheck.setEnabled(len(self.ui.lnProvider.text()) != 0)
+ self._reset_provider_check()
+
+ def _focus_password(self):
+ """
+ Focuses at the password lineedit for the registration page
+ """
+ self.ui.lblPassword.setFocus()
+
+ def _focus_second_password(self):
+ """
+ Focuses at the second password lineedit for the registration page
+ """
+ self.ui.lblPassword2.setFocus()
+
+ def _basic_password_checks(self, username, password, password2):
+ """
+ Performs basic password checks to avoid really easy passwords.
+
+ :param username: username provided at the registrarion form
+ :type username: str
+ :param password: password from the registration form
+ :type password: str
+ :param password2: second password from the registration form
+ :type password: str
+
+ :return: returns True if all the checks pass, False otherwise
+ :rtype: bool
+ """
+ message = None
+
+ if message is None and password != password2:
+ message = self.tr("Passwords don't match")
+
+ if message is None and len(password) < 6:
+ message = self.tr("Password too short")
+
+ if message is None and password in self.WEAK_PASSWORDS:
+ message = self.tr("Password too easy")
+
+ if message is None and username == password:
+ message = self.tr("Password equal to username")
+
+ if message is not None:
+ self._set_register_status(message, error=True)
+ self._focus_password()
+ return False
+
+ return True
+
+ def _register(self):
+ """
+ Performs the registration based on the values provided in the form
+ """
+ self.ui.btnRegister.setEnabled(False)
+
+ username = self.ui.lblUser.text()
+ password = self.ui.lblPassword.text()
+ password2 = self.ui.lblPassword2.text()
+
+ if self._basic_password_checks(username, password, password2):
+ register = SRPRegister(provider_config=self._provider_config)
+ register.registration_finished.connect(
+ self._registration_finished)
+
+ threads.deferToThread(
+ partial(register.register_user,
+ username.encode("utf8"),
+ password.encode("utf8")))
+
+ self._username = username
+ self._password = password
+ self._set_register_status(self.tr("Starting registration..."))
+ else:
+ self.ui.btnRegister.setEnabled(True)
+
+ def _set_registration_fields_visibility(self, visible):
+ """
+ This method hides the username and password labels and inputboxes.
+
+ :param visible: sets the visibility of the widgets
+ True: widgets are visible or False: are not
+ :type visible: bool
+ """
+ # username and password inputs
+ self.ui.lblUser.setVisible(visible)
+ self.ui.lblPassword.setVisible(visible)
+ self.ui.lblPassword2.setVisible(visible)
+
+ # username and password labels
+ self.ui.label_15.setVisible(visible)
+ self.ui.label_16.setVisible(visible)
+ self.ui.label_17.setVisible(visible)
+
+ # register button
+ self.ui.btnRegister.setVisible(visible)
+
+ def _registration_finished(self, ok, req):
+ if ok:
+ user_domain = self._username + "@" + self._domain
+ message = "<font color='green'><h3>"
+ message += self.tr("User %s successfully registered.") % (
+ user_domain, )
+ message += "</h3></font>"
+ self._set_register_status(message)
+
+ self.ui.lblPassword2.clearFocus()
+ self._set_registration_fields_visibility(False)
+
+ # Allow the user to remember his password
+ if has_keyring():
+ self.ui.chkRemember.setVisible(True)
+ self.ui.chkRemember.setEnabled(True)
+
+ self.page(self.REGISTER_USER_PAGE).set_completed()
+ self.button(QtGui.QWizard.BackButton).setEnabled(False)
+ else:
+ old_username = self._username
+ self._username = None
+ self._password = None
+ error_msg = self.tr("Unknown error")
+ try:
+ content, _ = get_content(req)
+ json_content = json.loads(content)
+ error_msg = json_content.get("errors").get("login")[0]
+ if not error_msg.istitle():
+ error_msg = "%s %s" % (old_username, error_msg)
+ except Exception as e:
+ logger.error("Unknown error: %r" % (e,))
+
+ self._set_register_status(error_msg, error=True)
+ self.ui.btnRegister.setEnabled(True)
+
+ def _set_register_status(self, status, error=False):
+ """
+ Sets the status label in the registration page to status
+
+ :param status: status message to display, can be HTML
+ :type status: str
+ """
+ if error:
+ status = "<font color='red'><b>%s</b></font>" % (status,)
+ self.ui.lblRegisterStatus.setText(status)
+
+ def _reset_provider_check(self):
+ """
+ Resets the UI for checking a provider. Also resets the domain
+ in this object.
+ """
+ self.ui.lblNameResolution.setPixmap(None)
+ self.ui.lblHTTPS.setPixmap(None)
+ self.ui.lblProviderInfo.setPixmap(None)
+ self.ui.lblProviderSelectStatus.setText("")
+ self._domain = None
+ self.button(QtGui.QWizard.NextButton).setEnabled(False)
+ self.page(self.SELECT_PROVIDER_PAGE).set_completed(False)
+
+ def _reset_provider_setup(self):
+ """
+ Resets the UI for setting up a provider.
+ """
+ self.ui.lblDownloadCaCert.setPixmap(None)
+ self.ui.lblCheckCaFpr.setPixmap(None)
+ self.ui.lblCheckApiCert.setPixmap(None)
+
+ def _check_provider(self):
+ """
+ SLOT
+ TRIGGERS:
+ self.ui.btnCheck.clicked
+ self.ui.lnProvider.returnPressed
+
+ Starts the checks for a given provider
+ """
+ if len(self.ui.lnProvider.text()) == 0:
+ return
+
+ self.ui.grpCheckProvider.setVisible(True)
+ self.ui.btnCheck.setEnabled(False)
+ self.ui.lnProvider.setEnabled(False)
+ self.button(QtGui.QWizard.BackButton).clearFocus()
+ self._domain = self.ui.lnProvider.text()
+
+ self.ui.lblNameResolution.setPixmap(self.QUESTION_ICON)
+ self._provider_select_defer = self._provider_bootstrapper.\
+ run_provider_select_checks(self._domain)
+
+ def _complete_task(self, data, label, complete=False, complete_page=-1):
+ """
+ Checks a task and completes a page if specified
+
+ :param data: data as it comes from the bootstrapper thread for
+ a specific check
+ :type data: dict
+ :param label: label that displays the status icon for a
+ specific check that corresponds to the data
+ :type label: QtGui.QLabel
+ :param complete: if True, it completes the page specified,
+ which must be of type WizardPage
+ :type complete: bool
+ :param complete_page: page id to complete
+ :type complete_page: int
+ """
+ passed = data[self._provider_bootstrapper.PASSED_KEY]
+ error = data[self._provider_bootstrapper.ERROR_KEY]
+ if passed:
+ label.setPixmap(self.OK_ICON)
+ if complete:
+ self.page(complete_page).set_completed()
+ self.button(QtGui.QWizard.NextButton).setFocus()
+ else:
+ label.setPixmap(self.ERROR_ICON)
+ logger.error(error)
+
+ def _name_resolution(self, data):
+ """
+ SLOT
+ TRIGGER: self._provider_bootstrapper.name_resolution
+
+ Sets the status for the name resolution check
+ """
+ self._complete_task(data, self.ui.lblNameResolution)
+ status = ""
+ passed = data[self._provider_bootstrapper.PASSED_KEY]
+ if not passed:
+ status = self.tr("<font color='red'><b>Non-existent "
+ "provider</b></font>")
+ else:
+ self.ui.lblHTTPS.setPixmap(self.QUESTION_ICON)
+ self.ui.lblProviderSelectStatus.setText(status)
+ self.ui.btnCheck.setEnabled(not passed)
+ self.ui.lnProvider.setEnabled(not passed)
+
+ def _https_connection(self, data):
+ """
+ SLOT
+ TRIGGER: self._provider_bootstrapper.https_connection
+
+ Sets the status for the https connection check
+ """
+ self._complete_task(data, self.ui.lblHTTPS)
+ status = ""
+ passed = data[self._provider_bootstrapper.PASSED_KEY]
+ if not passed:
+ status = self.tr("<font color='red'><b>%s</b></font>") \
+ % (data[self._provider_bootstrapper.ERROR_KEY])
+ self.ui.lblProviderSelectStatus.setText(status)
+ else:
+ self.ui.lblProviderInfo.setPixmap(self.QUESTION_ICON)
+ self.ui.btnCheck.setEnabled(not passed)
+ self.ui.lnProvider.setEnabled(not passed)
+
+ def _download_provider_info(self, data):
+ """
+ SLOT
+ TRIGGER: self._provider_bootstrapper.download_provider_info
+
+ Sets the status for the provider information download
+ check. Since this check is the last of this set, it also
+ completes the page if passed
+ """
+ if self._provider_config.load(os.path.join("leap",
+ "providers",
+ self._domain,
+ "provider.json")):
+ self._complete_task(data, self.ui.lblProviderInfo,
+ True, self.SELECT_PROVIDER_PAGE)
+ else:
+ new_data = {
+ self._provider_bootstrapper.PASSED_KEY: False,
+ self._provider_bootstrapper.ERROR_KEY:
+ self.tr("Unable to load provider configuration")
+ }
+ self._complete_task(new_data, self.ui.lblProviderInfo)
+
+ status = ""
+ if not data[self._provider_bootstrapper.PASSED_KEY]:
+ status = self.tr("<font color='red'><b>Not a valid provider"
+ "</b></font>")
+ self.ui.lblProviderSelectStatus.setText(status)
+ self.ui.btnCheck.setEnabled(True)
+ self.ui.lnProvider.setEnabled(True)
+
+ def _download_ca_cert(self, data):
+ """
+ SLOT
+ TRIGGER: self._provider_bootstrapper.download_ca_cert
+
+ Sets the status for the download of the CA certificate check
+ """
+ self._complete_task(data, self.ui.lblDownloadCaCert)
+ passed = data[self._provider_bootstrapper.PASSED_KEY]
+ if passed:
+ self.ui.lblCheckCaFpr.setPixmap(self.QUESTION_ICON)
+
+ def _check_ca_fingerprint(self, data):
+ """
+ SLOT
+ TRIGGER: self._provider_bootstrapper.check_ca_fingerprint
+
+ Sets the status for the CA fingerprint check
+ """
+ self._complete_task(data, self.ui.lblCheckCaFpr)
+ passed = data[self._provider_bootstrapper.PASSED_KEY]
+ if passed:
+ self.ui.lblCheckApiCert.setPixmap(self.QUESTION_ICON)
+
+ def _check_api_certificate(self, data):
+ """
+ SLOT
+ TRIGGER: self._provider_bootstrapper.check_api_certificate
+
+ Sets the status for the API certificate check. Also finishes
+ the provider bootstrapper thread since it's not needed anymore
+ from this point on, unless the whole check chain is restarted
+ """
+ self._complete_task(data, self.ui.lblCheckApiCert,
+ True, self.SETUP_PROVIDER_PAGE)
+
+ def _service_selection_changed(self, service, state):
+ """
+ SLOT
+ TRIGGER: service_checkbox.stateChanged
+ Adds the service to the state if the state is checked, removes
+ it otherwise
+
+ :param service: service to handle
+ :type service: str
+ :param state: state of the checkbox
+ :type state: int
+ """
+ if state == QtCore.Qt.Checked:
+ self._selected_services = \
+ self._selected_services.union(set([service]))
+ else:
+ self._selected_services = \
+ self._selected_services.difference(set([service]))
+
+ def _populate_services(self):
+ """
+ Loads the services that the provider provides into the UI for
+ the user to enable or disable.
+ """
+ self.ui.grpServices.setTitle(
+ self.tr("Services by %s") %
+ (self._provider_config.get_name(),))
+
+ services = get_supported(
+ self._provider_config.get_services())
+
+ for service in services:
+ try:
+ if service not in self._shown_services:
+ checkbox = QtGui.QCheckBox(self)
+ service_index = self.SERVICE_CONFIG.index(service)
+ checkbox.setText(self.SERVICE_DISPLAY[service_index])
+ self.ui.serviceListLayout.addWidget(checkbox)
+ checkbox.stateChanged.connect(
+ partial(self._service_selection_changed, service))
+ checkbox.setChecked(True)
+ self._shown_services.add(service)
+ except ValueError:
+ logger.error(
+ self.tr("Something went wrong while trying to "
+ "load service %s" % (service,)))
+
+ def _current_id_changed(self, pageId):
+ """
+ SLOT
+ TRIGGER: self.currentIdChanged
+
+ Prepares the pages when they appear
+ """
+ if pageId == self.SELECT_PROVIDER_PAGE:
+ self._reset_provider_check()
+ self._enable_check("")
+
+ if pageId == self.SETUP_PROVIDER_PAGE:
+ self._reset_provider_setup()
+ self.page(pageId).setSubTitle(self.tr("Gathering configuration "
+ "options for %s") %
+ (self._provider_config
+ .get_name(),))
+ self.ui.lblDownloadCaCert.setPixmap(self.QUESTION_ICON)
+ self._provider_setup_defer = self._provider_bootstrapper.\
+ run_provider_setup_checks(self._provider_config)
+
+ if pageId == self.PRESENT_PROVIDER_PAGE:
+ self.page(pageId).setSubTitle(self.tr("Description of services "
+ "offered by %s") %
+ (self._provider_config
+ .get_name(),))
+
+ lang = QtCore.QLocale.system().name()
+ self.ui.lblProviderName.setText(
+ "<b>%s</b>" %
+ (self._provider_config.get_name(lang=lang),))
+ self.ui.lblProviderURL.setText(
+ "https://%s" % (self._provider_config.get_domain(),))
+ self.ui.lblProviderDesc.setText(
+ "<i>%s</i>" %
+ (self._provider_config.get_description(lang=lang),))
+
+ self.ui.lblServicesOffered.setText(self._provider_config
+ .get_services_string())
+ self.ui.lblProviderPolicy.setText(self._provider_config
+ .get_enrollment_policy())
+
+ if pageId == self.REGISTER_USER_PAGE:
+ self.page(pageId).setSubTitle(self.tr("Register a new user with "
+ "%s") %
+ (self._provider_config
+ .get_name(),))
+ self.ui.chkRemember.setVisible(False)
+
+ if pageId == self.SERVICES_PAGE:
+ self._populate_services()
+
+ def _is_need_eip_password_warning(self):
+ """
+ Returns True if we need to add a warning about eip needing
+ administrative permissions to start. That can be either
+ because we are running in standalone mode, or because we could
+ not find the needed privilege escalation mechanisms being operative.
+ """
+ return self.standalone or is_missing_policy_permissions()
+
+ def nextId(self):
+ """
+ Sets the next page id for the wizard based on wether the user
+ wants to register a new identity or uses an existing one
+ """
+ if self.currentPage() == self.page(self.INTRO_PAGE):
+ self._show_register = self.ui.rdoRegister.isChecked()
+
+ if self.currentPage() == self.page(self.SETUP_PROVIDER_PAGE):
+ if self._show_register:
+ return self.REGISTER_USER_PAGE
+ else:
+ return self.SERVICES_PAGE
+
+ return QtGui.QWizard.nextId(self)
diff --git a/src/leap/bitmask/gui/wizardpage.py b/src/leap/bitmask/gui/wizardpage.py
new file mode 100644
index 00000000..b2a00028
--- /dev/null
+++ b/src/leap/bitmask/gui/wizardpage.py
@@ -0,0 +1,40 @@
+# -*- coding: utf-8 -*-
+# wizardpage.py
+# Copyright (C) 2013 LEAP
+#
+# 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/>.
+
+from PySide import QtGui
+
+
+class WizardPage(QtGui.QWizardPage):
+ """
+ Simple wizard page helper
+ """
+
+ def __init__(self):
+ QtGui.QWizardPage.__init__(self)
+ self._completed = False
+
+ def set_completed(self, val=True):
+ self._completed = val
+ if val:
+ self.completeChanged.emit()
+
+ def isComplete(self):
+ return self._completed
+
+ def cleanupPage(self):
+ self._completed = False
+ QtGui.QWizardPage.cleanupPage(self)
diff --git a/src/leap/bitmask/platform_init/__init__.py b/src/leap/bitmask/platform_init/__init__.py
new file mode 100644
index 00000000..2a262a30
--- /dev/null
+++ b/src/leap/bitmask/platform_init/__init__.py
@@ -0,0 +1,28 @@
+# -*- coding: utf-8 -*-
+# __init__.py
+# Copyright (C) 2013 LEAP
+#
+# 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/>.
+
+"""
+System constants
+"""
+import platform
+
+_system = platform.system()
+
+IS_WIN = True if _system == "Windows" else False
+IS_MAC = True if _system == "Darwin" else False
+IS_LINUX = True if _system == "Linux" else False
+IS_UNIX = IS_MAC or IS_LINUX
diff --git a/src/leap/bitmask/platform_init/initializers.py b/src/leap/bitmask/platform_init/initializers.py
new file mode 100644
index 00000000..831c6a1c
--- /dev/null
+++ b/src/leap/bitmask/platform_init/initializers.py
@@ -0,0 +1,409 @@
+# -*- coding: utf-8 -*-
+# initializers.py
+# Copyright (C) 2013 LEAP
+#
+# 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/>.
+
+"""
+Platform dependant initializing code
+"""
+
+import logging
+import os
+import platform
+import stat
+import subprocess
+import tempfile
+
+from PySide import QtGui
+
+from leap.bitmask.config.leapsettings import LeapSettings
+from leap.bitmask.services.eip import vpnlaunchers
+from leap.bitmask.util import first
+from leap.bitmask.util import privilege_policies
+
+
+logger = logging.getLogger(__name__)
+
+# NOTE we could use a deferToThread here, but should
+# be aware of this bug: http://www.themacaque.com/?p=1067
+
+__all__ = ["init_platform"]
+
+_system = platform.system()
+
+
+def init_platform():
+ """
+ Returns the right initializer for the platform we are running in, or
+ None if no proper initializer is found
+ """
+ initializer = None
+ try:
+ initializer = globals()[_system + "Initializer"]
+ except:
+ pass
+ if initializer:
+ logger.debug("Running initializer for %s" % (platform.system(),))
+ initializer()
+ else:
+ logger.debug("Initializer not found for %s" % (platform.system(),))
+
+
+#
+# common utils
+#
+
+NOTFOUND_MSG = ("Tried to install %s, but %s "
+ "not found inside this bundle.")
+BADEXEC_MSG = ("Tried to install %s, but %s "
+ "failed to %s.")
+
+UPDOWN_NOTFOUND_MSG = NOTFOUND_MSG % (
+ "updown scripts", "those were")
+UPDOWN_BADEXEC_MSG = BADEXEC_MSG % (
+ "updown scripts", "they", "be copied")
+
+
+def get_missing_updown_dialog():
+ """
+ Creates a dialog for notifying of missing updown scripts.
+ Returns that dialog.
+
+ :rtype: QtGui.QMessageBox instance
+ """
+ WE_NEED_POWERS = ("To better protect your privacy, "
+ "Bitmask needs administrative privileges "
+ "to install helper files. "
+ "Do you want to proceed?")
+ msg = QtGui.QMessageBox()
+ msg.setWindowTitle(msg.tr("Missing up/down scripts"))
+ msg.setText(msg.tr(WE_NEED_POWERS))
+ # but maybe the user really deserve to know more
+ #msg.setInformativeText(msg.tr(BECAUSE))
+ msg.setStandardButtons(QtGui.QMessageBox.Yes | QtGui.QMessageBox.No)
+ msg.addButton("No, don't ask again", QtGui.QMessageBox.RejectRole)
+ msg.setDefaultButton(QtGui.QMessageBox.Yes)
+ return msg
+
+
+def check_missing():
+ """
+ Checks for the need of installing missing scripts, and
+ raises a dialog to ask user for permission to do it.
+ """
+ config = LeapSettings()
+ alert_missing = config.get_alert_missing_scripts()
+
+ launcher = vpnlaunchers.get_platform_launcher()
+ missing_scripts = launcher.missing_updown_scripts
+ missing_other = launcher.missing_other_files
+
+ if alert_missing and (missing_scripts() or missing_other()):
+ msg = get_missing_updown_dialog()
+ ret = msg.exec_()
+
+ if ret == QtGui.QMessageBox.Yes:
+ install_missing_fun = globals().get(
+ "_%s_install_missing_scripts" % (_system.lower(),),
+ None)
+ if not install_missing_fun:
+ logger.warning(
+ "Installer not found for platform %s." % (_system,))
+ return
+
+ # XXX maybe move constants to fun
+ ok = install_missing_fun(UPDOWN_BADEXEC_MSG, UPDOWN_NOTFOUND_MSG)
+ if not ok:
+ msg = QtGui.QMessageBox()
+ msg.setWindowTitle(msg.tr("Problem installing files"))
+ msg.setText(msg.tr('Some of the files could not be copied.'))
+ msg.setIcon(QtGui.QMessageBox.Warning)
+ msg.exec_()
+
+ elif ret == QtGui.QMessageBox.No:
+ logger.debug("Not installing missing scripts, "
+ "user decided to ignore our warning.")
+
+ elif ret == QtGui.QMessageBox.Rejected:
+ logger.debug(
+ "Setting alert_missing_scripts to False, we will not "
+ "ask again")
+ config.set_alert_missing_scripts(False)
+#
+# windows initializers
+#
+
+
+def _windows_has_tap_device():
+ """
+ Loops over the windows registry trying to find if the tap0901 tap driver
+ has been installed on this machine.
+ """
+ import _winreg as reg
+
+ adapter_key = 'SYSTEM\CurrentControlSet\Control\Class' \
+ '\{4D36E972-E325-11CE-BFC1-08002BE10318}'
+ with reg.OpenKey(reg.HKEY_LOCAL_MACHINE, adapter_key) as adapters:
+ try:
+ for i in xrange(10000):
+ key_name = reg.EnumKey(adapters, i)
+ with reg.OpenKey(adapters, key_name) as adapter:
+ try:
+ component_id = reg.QueryValueEx(adapter,
+ 'ComponentId')[0]
+ if component_id.startswith("tap0901"):
+ return True
+ except WindowsError:
+ pass
+ except WindowsError:
+ pass
+ return False
+
+
+def WindowsInitializer():
+ """
+ Raises a dialog in case that the windows tap driver has not been found
+ in the registry, asking the user for permission to install the driver
+ """
+ if not _windows_has_tap_device():
+ msg = QtGui.QMessageBox()
+ msg.setWindowTitle(msg.tr("TAP Driver"))
+ msg.setText(msg.tr("Bitmask needs to install the necessary drivers "
+ "for Encrypted Internet to work. Would you like to "
+ "proceed?"))
+ msg.setInformativeText(msg.tr("Encrypted Internet uses VPN, which "
+ "needs a TAP device installed and none "
+ "has been found. This will ask for "
+ "administrative privileges."))
+ msg.setStandardButtons(QtGui.QMessageBox.Yes | QtGui.QMessageBox.No)
+ msg.setDefaultButton(QtGui.QMessageBox.Yes)
+ ret = msg.exec_()
+
+ if ret == QtGui.QMessageBox.Yes:
+ # XXX should do this only if executed inside bundle.
+ # Let's assume it's the only way it's gonna be executed under win
+ # by now.
+ driver_path = os.path.join(os.getcwd(),
+ "apps",
+ "eip",
+ "tap_driver")
+ dev_installer = os.path.join(driver_path,
+ "devcon.exe")
+ if os.path.isfile(dev_installer) and \
+ stat.S_IXUSR & os.stat(dev_installer)[stat.ST_MODE] != 0:
+ inf_path = os.path.join(driver_path,
+ "OemWin2k.inf")
+ cmd = [dev_installer, "install", inf_path, "tap0901"]
+ ret = subprocess.call(cmd, stdout=subprocess.PIPE, shell=True)
+ else:
+ logger.error("Tried to install TAP driver, but the installer "
+ "is not found or not executable")
+
+#
+# Darwin initializer functions
+#
+
+
+def _darwin_has_tun_kext():
+ """
+ Returns True only if we found a directory under the system kext folder
+ containing a kext named tun.kext, AND we found a startup item named 'tun'
+ """
+ # XXX we should be smarter here and use kextstats output.
+
+ has_kext = os.path.isdir("/Library/Extensions/tun.kext")
+ has_startup = os.path.isdir("/Library/StartupItems/tun")
+ has_tun_and_startup = has_kext and has_startup
+ logger.debug(
+ 'platform initializer check: has tun_and_startup = %s' %
+ (has_tun_and_startup,))
+ return has_tun_and_startup
+
+
+def _darwin_install_missing_scripts(badexec, notfound):
+ """
+ Tries to install the missing up/down scripts.
+
+ :param badexec: error for notifying execution error during command.
+ :type badexec: str
+ :param notfound: error for notifying missing path.
+ :type notfound: str
+ :returns: True if the files could be copied successfully.
+ :rtype: bool
+ """
+ # We expect to execute this from some way of bundle, since
+ # the up/down scripts should be put in place by the installer.
+ success = False
+ installer_path = os.path.join(
+ os.getcwd(),
+ "..",
+ "Resources",
+ "openvpn")
+ launcher = vpnlaunchers.DarwinVPNLauncher
+
+ if os.path.isdir(installer_path):
+ fd, tempscript = tempfile.mkstemp(prefix="leap_installer-")
+ try:
+ scriptlines = launcher.cmd_for_missing_scripts(installer_path)
+ with os.fdopen(fd, 'w') as f:
+ f.write(scriptlines)
+ st = os.stat(tempscript)
+ os.chmod(tempscript, st.st_mode | stat.S_IEXEC | stat.S_IXUSR |
+ stat.S_IXGRP | stat.S_IXOTH)
+
+ cmd, args = launcher().get_cocoasudo_installmissing_cmd()
+ args.append(tempscript)
+ cmdline = " ".join([cmd] + args)
+ ret = subprocess.call(
+ cmdline, stdout=subprocess.PIPE,
+ shell=True)
+ success = ret == 0
+ if not success:
+ logger.error("Install missing scripts failed.")
+ except Exception as exc:
+ logger.error(badexec)
+ logger.error("Error was: %r" % (exc,))
+ finally:
+ try:
+ os.remove(tempscript)
+ except OSError as exc:
+ logger.error("%r" % (exc,))
+ else:
+ logger.error(notfound)
+ logger.debug('path searched: %s' % (installer_path,))
+
+ return success
+
+
+def DarwinInitializer():
+ """
+ Raises a dialog in case that the osx tuntap driver has not been found
+ in the registry, asking the user for permission to install the driver
+ """
+ # XXX split this function into several
+
+ TUNTAP_NOTFOUND_MSG = NOTFOUND_MSG % (
+ "tuntaposx kext", "the installer")
+ TUNTAP_BADEXEC_MSG = BADEXEC_MSG % (
+ "tuntaposx kext", "the installer", "be launched")
+
+ # TODO DRY this with other cases, and
+ # factor out to _should_install() function.
+ # Leave the dialog as a more generic thing.
+
+ if not _darwin_has_tun_kext():
+ msg = QtGui.QMessageBox()
+ msg.setWindowTitle(msg.tr("TUN Driver"))
+ msg.setText(msg.tr("Bitmask needs to install the necessary drivers "
+ "for Encrypted Internet to work. Would you like to "
+ "proceed?"))
+ msg.setInformativeText(msg.tr("Encrypted Internet uses VPN, which "
+ "needs a kernel extension for a TUN "
+ "device installed, and none "
+ "has been found. This will ask for "
+ "administrative privileges."))
+ msg.setStandardButtons(QtGui.QMessageBox.Yes | QtGui.QMessageBox.No)
+ msg.setDefaultButton(QtGui.QMessageBox.Yes)
+ ret = msg.exec_()
+
+ if ret == QtGui.QMessageBox.Yes:
+ installer_path = os.path.abspath(
+ os.path.join(
+ os.getcwd(),
+ "..",
+ "Resources",
+ "tuntap-installer.app"))
+ if os.path.isdir(installer_path):
+ cmd = ["open '%s'" % (installer_path,)]
+ try:
+ ret = subprocess.call(
+ cmd, stdout=subprocess.PIPE,
+ shell=True)
+ except:
+ logger.error(TUNTAP_BADEXEC_MSG)
+ else:
+ logger.error(TUNTAP_NOTFOUND_MSG)
+
+ # Second check, for missing scripts.
+ check_missing()
+
+
+#
+# Linux initializers
+#
+def _linux_install_missing_scripts(badexec, notfound):
+ """
+ Tries to install the missing up/down scripts.
+
+ :param badexec: error for notifying execution error during command.
+ :type badexec: str
+ :param notfound: error for notifying missing path.
+ :type notfound: str
+ :returns: True if the files could be copied successfully.
+ :rtype: bool
+ """
+ success = False
+ installer_path = os.path.join(os.getcwd(), "apps", "eip", "files")
+ launcher = vpnlaunchers.LinuxVPNLauncher
+
+ # XXX refactor with darwin, same block.
+
+ if os.path.isdir(installer_path):
+ fd, tempscript = tempfile.mkstemp(prefix="leap_installer-")
+ polfd, pol_tempfile = tempfile.mkstemp(prefix="leap_installer-")
+ try:
+ path = launcher.OPENVPN_BIN_PATH
+ policy_contents = privilege_policies.get_policy_contents(path)
+
+ with os.fdopen(polfd, 'w') as f:
+ f.write(policy_contents)
+
+ pkexec = first(launcher.maybe_pkexec())
+ scriptlines = launcher.cmd_for_missing_scripts(installer_path,
+ pol_tempfile)
+ with os.fdopen(fd, 'w') as f:
+ f.write(scriptlines)
+
+ st = os.stat(tempscript)
+ os.chmod(tempscript, st.st_mode | stat.S_IEXEC | stat.S_IXUSR |
+ stat.S_IXGRP | stat.S_IXOTH)
+ cmdline = ["%s %s" % (pkexec, tempscript)]
+ ret = subprocess.call(
+ cmdline, stdout=subprocess.PIPE,
+ shell=True)
+ success = ret == 0
+ if not success:
+ logger.error("Install missing scripts failed.")
+ except Exception as exc:
+ logger.error(badexec)
+ logger.error("Error was: %r" % (exc,))
+ finally:
+ try:
+ os.remove(tempscript)
+ except OSError as exc:
+ logger.error("%r" % (exc,))
+ else:
+ logger.error(notfound)
+ logger.debug('path searched: %s' % (installer_path,))
+
+ return success
+
+
+def LinuxInitializer():
+ """
+ Raises a dialog in case that either updown scripts or policykit file
+ are missing or they have incorrect permissions.
+ """
+ check_missing()
diff --git a/src/leap/bitmask/platform_init/locks.py b/src/leap/bitmask/platform_init/locks.py
new file mode 100644
index 00000000..ecfe3b1f
--- /dev/null
+++ b/src/leap/bitmask/platform_init/locks.py
@@ -0,0 +1,405 @@
+# -*- coding: utf-8 -*-
+# locks.py
+# Copyright (C) 2013 LEAP
+#
+# 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/>.
+"""
+Utilities for handling multi-platform file locking mechanisms
+"""
+import logging
+import errno
+import os
+import platform
+
+from leap.bitmask import platform_init
+from leap.common.events import signal as signal_event
+from leap.common.events import events_pb2 as proto
+
+if platform_init.IS_UNIX:
+ from fcntl import flock, LOCK_EX, LOCK_NB
+else: # WINDOWS
+ import datetime
+ import glob
+ import shutil
+ import time
+
+ from tempfile import gettempdir
+
+ from leap.bitmask.util import get_modification_ts, update_modification_ts
+
+logger = logging.getLogger(__name__)
+
+if platform_init.IS_UNIX:
+
+ class UnixLock(object):
+ """
+ Uses flock to get an exclusive lock over a file.
+ See man 2 flock
+ """
+
+ def __init__(self, path):
+ """
+ iniializes t he UnixLock with the path of the
+ desired lockfile
+ """
+
+ self._fd = None
+ self.path = path
+
+ def get_lock(self):
+ """
+ Tries to get a lock, and writes the running pid there if successful
+ """
+ gotit, pid = self._get_lock_and_pid()
+ return gotit
+
+ def get_pid(self):
+ """
+ Returns the pid of the locking process
+ """
+ gotit, pid = self._get_lock_and_pid()
+ return pid
+
+ def _get_lock(self):
+ """
+ Tries to get a lock, returning True if successful
+
+ :rtype: bool
+ """
+ self._fd = os.open(self.path, os.O_CREAT | os.O_RDWR)
+
+ try:
+ flock(self._fd, LOCK_EX | LOCK_NB)
+ except IOError as exc:
+ # could not get the lock
+ #import ipdb; ipdb.set_trace()
+
+ if exc.args[0] in (errno.EDEADLK, errno.EAGAIN):
+ # errno 11 or 35
+ # Resource temporarily unavailable
+ return False
+ else:
+ raise
+ return True
+
+ @property
+ def locked_by_us(self):
+ """
+ Returns True if the pid in the pidfile
+ is ours.
+
+ :rtype: bool
+ """
+ gotit, pid = self._get_lock_and_pid()
+ return pid == os.getpid()
+
+ def _get_lock_and_pid(self):
+ """
+ Tries to get a lock over the file.
+ Returns (locked, pid) tuple.
+
+ :rtype: tuple
+ """
+
+ if self._get_lock():
+ self._write_to_pidfile()
+ return True, None
+
+ return False, self._read_from_pidfile()
+
+ def _read_from_pidfile(self):
+ """
+ Tries to read pid from the pidfile,
+ returns False if no content found.
+ """
+
+ pidfile = os.read(
+ self._fd, 16)
+ if not pidfile:
+ return False
+
+ try:
+ return int(pidfile.strip())
+ except Exception as exc:
+ exc.args += (pidfile, self.lock_file)
+ raise
+
+ def _write_to_pidfile(self):
+ """
+ Writes the pid of the running process
+ to the pidfile
+ """
+ fd = self._fd
+ os.ftruncate(fd, 0)
+ os.write(fd, '%d\n' % os.getpid())
+ os.fsync(fd)
+
+
+if platform_init.IS_WIN:
+
+ # Time to wait (in secs) before assuming a raise window signal has not been
+ # ack-ed.
+
+ RAISE_WINDOW_TIMEOUT = 2
+
+ # How many steps to do while checking lockfile ts update.
+
+ RAISE_WINDOW_WAIT_STEPS = 10
+
+ def _release_lock(name):
+ """
+ Tries to remove a folder path.
+
+ :param name: folder lock to remove
+ :type name: str
+ """
+ try:
+ shutil.rmtree(name)
+ return True
+ except WindowsError as exc:
+ if exc.errno in (errno.EPIPE, errno.ENOENT,
+ errno.ESRCH, errno.EACCES):
+ logger.warning(
+ 'exception while trying to remove the lockfile dir')
+ logger.warning('errno %s: %s' % (exc.errno, exc.args[1]))
+ # path does not exist
+ return False
+ else:
+ logger.debug('errno = %s' % (exc.errno,))
+ # we did not foresee this error, better add it explicitely
+ raise
+
+ class WindowsLock(object):
+ """
+ Creates a lock based on the atomic nature of mkdir on Windows
+ system calls.
+ """
+ LOCKBASE = os.path.join(gettempdir(), "leap-client-lock")
+
+ def __init__(self):
+ """
+ Initializes the lock.
+ Sets the lock name to basename plus the process pid.
+ """
+ self._fd = None
+ pid = os.getpid()
+ self.name = "%s-%s" % (self.LOCKBASE, pid)
+ self.pid = pid
+
+ def get_lock(self):
+ """
+ Tries to get a lock, and writes the running pid there if successful
+ """
+ gotit = self._get_lock()
+ return gotit
+
+ def _get_lock(self):
+ """
+ Tries to write to a file with the current pid as part of the name
+ """
+ try:
+ self._fd = os.makedirs(self.name)
+ except OSError as exc:
+ # could not create the dir
+ if exc.args[0] == 183:
+ logger.debug('cannot create dir')
+ # cannot create dir with existing name
+ return False
+ else:
+ raise
+ return self._is_one_pidfile()[0]
+
+ def _is_one_pidfile(self):
+ """
+ Returns True, pid if there is only one pidfile with the expected
+ base path
+
+ :rtype: tuple
+ """
+ pidfiles = glob.glob(self.LOCKBASE + '-*')
+ if len(pidfiles) == 1:
+ pid = pidfiles[0].split('-')[-1]
+ return True, int(pid)
+ else:
+ return False, None
+
+ def get_pid(self):
+ """
+ Returns the pid of the locking process.
+
+ :rtype: int
+ """
+ # XXX assert there is only one?
+ _, pid = self._is_one_pidfile()
+ return pid
+
+ def get_locking_path(self):
+ """
+ Returns the pid path of the locking process.
+
+ :rtype: str
+ """
+ pid = self.get_pid()
+ if pid:
+ return "%s-%s" % (self.LOCKBASE, pid)
+
+ def release_lock(self, name=None):
+ """
+ Releases the pidfile dir for this process, by removing it.
+ """
+ if not name:
+ name = self.name
+ _release_lock(name)
+
+ @classmethod
+ def release_all_locks(self):
+ """
+ Releases all locks. Used for clean shutdown.
+ """
+ for lockdir in glob.glob("%s-%s" % (self.LOCKBASE, '*')):
+ _release_lock(lockdir)
+
+ @property
+ def locked_by_us(self):
+ """
+ Returns True if the pid in the pidfile
+ is ours.
+
+ :rtype: bool
+ """
+ _, pid = self._is_one_pidfile()
+ return pid == self.pid
+
+ def update_ts(self):
+ """
+ Updates the timestamp of the lock.
+ """
+ if self.locked_by_us:
+ update_modification_ts(self.name)
+
+ def write_port(self, port):
+ """
+ Writes the port for windows control to the pidfile folder
+ Returns True if successful.
+
+ :rtype: bool
+ """
+ if not self.locked_by_us:
+ logger.warning("Tried to write control port to a "
+ "non-unique pidfile folder")
+ return False
+ port_file = os.path.join(self.name, "port")
+ with open(port_file, 'w') as f:
+ f.write("%s" % port)
+ return True
+
+ def get_control_port(self):
+ """
+ Reads control port of the main instance from the port file
+ in the pidfile dir
+
+ :rtype: int
+ """
+ pid = self.get_pid()
+ port_file = os.path.join(self.LOCKBASE + "-%s" % pid, "port")
+ port = None
+ try:
+ with open(port_file) as f:
+ port_str = f.read()
+ port = int(port_str.strip())
+ except IOError as exc:
+ if exc.errno == errno.ENOENT:
+ logger.error("Tried to read port from non-existent file")
+ else:
+ # we did not know explicitely about this error
+ raise
+ return port
+
+ def raise_window_ack():
+ """
+ This function is called from the windows callback that is registered
+ with the raise_window event. It just updates the modification time
+ of the lock file so we can signal an ack to the instance that tried
+ to raise the window.
+ """
+ lock = WindowsLock()
+ lock.update_ts()
+
+
+def we_are_the_one_and_only():
+ """
+ Returns True if we are the only instance running, False otherwise.
+ If we came later, send a raise signal to the main instance of the
+ application.
+
+ Under windows we are not using flock magic, so we wait during
+ RAISE_WINDOW_TIMEOUT time, if not ack is
+ received, we assume it was a stalled lock, so we remove it and continue
+ with initialization.
+
+ :rtype: bool
+ """
+ _sys = platform.system()
+
+ if _sys in ("Linux", "Darwin"):
+ locker = UnixLock('/tmp/leap-client.lock')
+ locker.get_lock()
+ we_are_the_one = locker.locked_by_us
+ if not we_are_the_one:
+ signal_event(proto.RAISE_WINDOW)
+ return we_are_the_one
+
+ elif _sys == "Windows":
+ locker = WindowsLock()
+ locker.get_lock()
+ we_are_the_one = locker.locked_by_us
+
+ if not we_are_the_one:
+ locker.release_lock()
+ lock_path = locker.get_locking_path()
+ ts = get_modification_ts(lock_path)
+
+ nowfun = datetime.datetime.now
+ t0 = nowfun()
+ pause = RAISE_WINDOW_TIMEOUT / float(RAISE_WINDOW_WAIT_STEPS)
+ timeout_delta = datetime.timedelta(0, RAISE_WINDOW_TIMEOUT)
+ check_interval = lambda: nowfun() - t0 < timeout_delta
+
+ # let's assume it's a stalled lock
+ we_are_the_one = True
+ signal_event(proto.RAISE_WINDOW)
+
+ while check_interval():
+ if get_modification_ts(lock_path) > ts:
+ # yay! someone claimed their control over the lock.
+ # so the lock is alive
+ logger.debug('Raise window ACK-ed')
+ we_are_the_one = False
+ break
+ else:
+ time.sleep(pause)
+
+ if we_are_the_one:
+ # ok, it really was a stalled lock. let's remove all
+ # that is left, and put only ours there.
+ WindowsLock.release_all_locks()
+ WindowsLock().get_lock()
+
+ return we_are_the_one
+
+ else:
+ logger.warning("Multi-instance checker "
+ "not implemented for %s" % (_sys))
+ # lies, lies, lies...
+ return True
diff --git a/src/leap/bitmask/provider/__init__.py b/src/leap/bitmask/provider/__init__.py
new file mode 100644
index 00000000..e69de29b
--- /dev/null
+++ b/src/leap/bitmask/provider/__init__.py
diff --git a/src/leap/bitmask/provider/supportedapis.py b/src/leap/bitmask/provider/supportedapis.py
new file mode 100644
index 00000000..3e650ba2
--- /dev/null
+++ b/src/leap/bitmask/provider/supportedapis.py
@@ -0,0 +1,38 @@
+# -*- coding: utf-8 -*-
+# supportedapis.py
+# Copyright (C) 2013 LEAP
+#
+# 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/>.
+
+"""
+API Support check.
+"""
+
+
+class SupportedAPIs(object):
+ """
+ Class responsible of checking for API compatibility.
+ """
+ SUPPORTED_APIS = ["1"]
+
+ @classmethod
+ def supports(self, api_version):
+ """
+ :param api_version: the version number of the api that we need to check
+ :type api_version: str
+
+ :returns: if that version is supported or not.
+ :return type: bool
+ """
+ return api_version in self.SUPPORTED_APIS
diff --git a/src/leap/bitmask/services/__init__.py b/src/leap/bitmask/services/__init__.py
new file mode 100644
index 00000000..253359cd
--- /dev/null
+++ b/src/leap/bitmask/services/__init__.py
@@ -0,0 +1,33 @@
+# -*- coding: utf-8 -*-
+# __init__.py
+# Copyright (C) 2013 LEAP
+#
+# 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/>.
+"""
+Services module.
+"""
+DEPLOYED = ["openvpn", "mx"]
+
+
+def get_supported(services):
+ """
+ Returns a list of the available services.
+
+ :param services: a list containing the services to be filtered.
+ :type services: list of str
+
+ :returns: a list of the available services
+ :rtype: list of str
+ """
+ return filter(lambda s: s in DEPLOYED, services)
diff --git a/src/leap/bitmask/services/abstractbootstrapper.py b/src/leap/bitmask/services/abstractbootstrapper.py
new file mode 100644
index 00000000..6f246f47
--- /dev/null
+++ b/src/leap/bitmask/services/abstractbootstrapper.py
@@ -0,0 +1,164 @@
+# -*- coding: utf-8 -*-
+# abstractbootstrapper.py
+# Copyright (C) 2013 LEAP
+#
+# 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/>.
+
+"""
+Abstract bootstrapper implementation
+"""
+import logging
+
+import requests
+
+from functools import partial
+
+from PySide import QtCore
+from twisted.internet import threads
+
+from leap.common.check import leap_assert, leap_assert_type
+
+logger = logging.getLogger(__name__)
+
+
+class AbstractBootstrapper(QtCore.QObject):
+ """
+ Abstract Bootstrapper that implements the needed deferred callbacks
+ """
+
+ PASSED_KEY = "passed"
+ ERROR_KEY = "error"
+
+ def __init__(self, bypass_checks=False):
+ """
+ Constructor for the abstract bootstrapper
+
+ :param bypass_checks: Set to true if the app should bypass
+ first round of checks for CA
+ certificates at bootstrap
+ :type bypass_checks: bool
+ """
+ QtCore.QObject.__init__(self)
+
+ leap_assert(self._gui_errback.im_func ==
+ AbstractBootstrapper._gui_errback.im_func,
+ "Cannot redefine _gui_errback")
+ leap_assert(self._errback.im_func ==
+ AbstractBootstrapper._errback.im_func,
+ "Cannot redefine _errback")
+ leap_assert(self._gui_notify.im_func ==
+ AbstractBootstrapper._gui_notify.im_func,
+ "Cannot redefine _gui_notify")
+
+ # **************************************************** #
+ # Dependency injection helpers, override this for more
+ # granular testing
+ self._fetcher = requests
+ # **************************************************** #
+
+ self._session = self._fetcher.session()
+ self._bypass_checks = bypass_checks
+ self._signal_to_emit = None
+ self._err_msg = None
+
+ def _gui_errback(self, failure):
+ """
+ Errback used to notify the GUI of a problem, it should be used
+ as the last errback of the whole chain.
+
+ Traps all exceptions if a signal is defined, otherwise it just
+ lets it continue.
+
+ NOTE: This method is final, it should not be redefined.
+
+ :param failure: failure object that Twisted generates
+ :type failure: twisted.python.failure.Failure
+ """
+ if self._signal_to_emit:
+ err_msg = self._err_msg \
+ if self._err_msg is not None \
+ else str(failure.value)
+ self._signal_to_emit.emit({
+ self.PASSED_KEY: False,
+ self.ERROR_KEY: err_msg
+ })
+ failure.trap(Exception)
+
+ def _errback(self, failure, signal=None):
+ """
+ Regular errback used for the middle of the chain. If it's
+ executed, the first one will set the signal to emit as
+ failure.
+
+ NOTE: This method is final, it should not be redefined.
+
+ :param failure: failure object that Twisted generates
+ :type failure: twisted.python.failure.Failure
+ :param signal: Signal to emit if it fails here first
+ :type signal: QtCore.SignalInstance
+
+ :returns: failure object that Twisted generates
+ :rtype: twisted.python.failure.Failure
+ """
+ if self._signal_to_emit is None:
+ self._signal_to_emit = signal
+ return failure
+
+ def _gui_notify(self, _, signal=None):
+ """
+ Callback used to notify the GUI of a success. Will emit signal
+ if specified
+
+ NOTE: This method is final, it should not be redefined.
+
+ :param _: IGNORED. Returned from the previous callback
+ :type _: IGNORED
+ :param signal: Signal to emit if it fails here first
+ :type signal: QtCore.SignalInstance
+ """
+ if signal:
+ logger.debug("Emitting %s" % (signal,))
+ signal.emit({self.PASSED_KEY: True, self.ERROR_KEY: ""})
+
+ def _callback_threader(self, cb, res, *args, **kwargs):
+ return threads.deferToThread(cb, res, *args, **kwargs)
+
+ def addCallbackChain(self, callbacks):
+ """
+ Creates a callback/errback chain on another thread using
+ deferToThread and adds the _gui_errback to the end to notify
+ the GUI on an error.
+
+ :param callbacks: List of tuples of callbacks and the signal
+ associated to that callback
+ :type callbacks: list(tuple(func, func))
+
+ :returns: the defer with the callback chain
+ :rtype: deferred
+ """
+ leap_assert_type(callbacks, list)
+
+ self._signal_to_emit = None
+ self._err_msg = None
+
+ d = None
+ for cb, sig in callbacks:
+ if d is None:
+ d = threads.deferToThread(cb)
+ else:
+ d.addCallback(partial(self._callback_threader, cb))
+ d.addErrback(self._errback, signal=sig)
+ d.addCallback(self._gui_notify, signal=sig)
+ d.addErrback(self._gui_errback)
+ return d
diff --git a/src/leap/bitmask/services/eip/__init__.py b/src/leap/bitmask/services/eip/__init__.py
new file mode 100644
index 00000000..e69de29b
--- /dev/null
+++ b/src/leap/bitmask/services/eip/__init__.py
diff --git a/src/leap/bitmask/services/eip/eipbootstrapper.py b/src/leap/bitmask/services/eip/eipbootstrapper.py
new file mode 100644
index 00000000..6393e53a
--- /dev/null
+++ b/src/leap/bitmask/services/eip/eipbootstrapper.py
@@ -0,0 +1,183 @@
+# -*- coding: utf-8 -*-
+# eipbootstrapper.py
+# Copyright (C) 2013 LEAP
+#
+# 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/>.
+
+"""
+EIP bootstrapping
+"""
+
+import logging
+import os
+
+from PySide import QtCore
+
+from leap.bitmask.config.providerconfig import ProviderConfig
+from leap.bitmask.crypto.srpauth import SRPAuth
+from leap.bitmask.services.eip.eipconfig import EIPConfig
+from leap.bitmask.util.request_helpers import get_content
+from leap.bitmask.util.constants import REQUEST_TIMEOUT
+from leap.bitmask.services.abstractbootstrapper import AbstractBootstrapper
+from leap.common import certs
+from leap.common.check import leap_assert, leap_assert_type
+from leap.common.files import check_and_fix_urw_only, get_mtime, mkdir_p
+
+logger = logging.getLogger(__name__)
+
+
+class EIPBootstrapper(AbstractBootstrapper):
+ """
+ Sets up EIP for a provider a series of checks and emits signals
+ after they are passed.
+ If a check fails, the subsequent checks are not executed
+ """
+
+ # All dicts returned are of the form
+ # {"passed": bool, "error": str}
+ download_config = QtCore.Signal(dict)
+ download_client_certificate = QtCore.Signal(dict)
+
+ def __init__(self):
+ AbstractBootstrapper.__init__(self)
+
+ self._provider_config = None
+ self._eip_config = None
+ self._download_if_needed = False
+
+ def _download_config(self, *args):
+ """
+ Downloads the EIP config for the given provider
+ """
+
+ leap_assert(self._provider_config,
+ "We need a provider configuration!")
+
+ logger.debug("Downloading EIP config for %s" %
+ (self._provider_config.get_domain(),))
+
+ api_version = self._provider_config.get_api_version()
+ self._eip_config = EIPConfig()
+ self._eip_config.set_api_version(api_version)
+
+ headers = {}
+ mtime = get_mtime(os.path.join(self._eip_config
+ .get_path_prefix(),
+ "leap",
+ "providers",
+ self._provider_config.get_domain(),
+ "eip-service.json"))
+
+ if self._download_if_needed and mtime:
+ headers['if-modified-since'] = mtime
+
+ # there is some confusion with this uri,
+ # it's in 1/config/eip, config/eip and config/1/eip...
+ config_uri = "%s/%s/config/eip-service.json" % (
+ self._provider_config.get_api_uri(),
+ api_version)
+ logger.debug('Downloading eip config from: %s' % config_uri)
+
+ res = self._session.get(config_uri,
+ verify=self._provider_config
+ .get_ca_cert_path(),
+ headers=headers,
+ timeout=REQUEST_TIMEOUT)
+ res.raise_for_status()
+
+ # Not modified
+ if res.status_code == 304:
+ logger.debug("EIP definition has not been modified")
+ else:
+ eip_definition, mtime = get_content(res)
+
+ self._eip_config.load(data=eip_definition, mtime=mtime)
+ self._eip_config.save(["leap",
+ "providers",
+ self._provider_config.get_domain(),
+ "eip-service.json"])
+
+ def _download_client_certificates(self, *args):
+ """
+ Downloads the EIP client certificate for the given provider
+ """
+ leap_assert(self._provider_config, "We need a provider configuration!")
+ leap_assert(self._eip_config, "We need an eip configuration!")
+
+ logger.debug("Downloading EIP client certificate for %s" %
+ (self._provider_config.get_domain(),))
+
+ client_cert_path = self._eip_config.\
+ get_client_cert_path(self._provider_config,
+ about_to_download=True)
+
+ # For re-download if something is wrong with the cert
+ self._download_if_needed = self._download_if_needed and \
+ not certs.should_redownload(client_cert_path)
+
+ if self._download_if_needed and \
+ os.path.exists(client_cert_path):
+ check_and_fix_urw_only(client_cert_path)
+ return
+
+ srp_auth = SRPAuth(self._provider_config)
+ session_id = srp_auth.get_session_id()
+ cookies = None
+ if session_id:
+ cookies = {"_session_id": session_id}
+ cert_uri = "%s/%s/cert" % (
+ self._provider_config.get_api_uri(),
+ self._provider_config.get_api_version())
+ logger.debug('getting cert from uri: %s' % cert_uri)
+ res = self._session.get(cert_uri,
+ verify=self._provider_config
+ .get_ca_cert_path(),
+ cookies=cookies,
+ timeout=REQUEST_TIMEOUT)
+ res.raise_for_status()
+ client_cert = res.content
+
+ if not certs.is_valid_pemfile(client_cert):
+ raise Exception(self.tr("The downloaded certificate is not a "
+ "valid PEM file"))
+
+ mkdir_p(os.path.dirname(client_cert_path))
+
+ with open(client_cert_path, "w") as f:
+ f.write(client_cert)
+
+ check_and_fix_urw_only(client_cert_path)
+
+ def run_eip_setup_checks(self,
+ provider_config,
+ download_if_needed=False):
+ """
+ Starts the checks needed for a new eip setup
+
+ :param provider_config: Provider configuration
+ :type provider_config: ProviderConfig
+ """
+ leap_assert(provider_config, "We need a provider config!")
+ leap_assert_type(provider_config, ProviderConfig)
+
+ self._provider_config = provider_config
+ self._download_if_needed = download_if_needed
+
+ cb_chain = [
+ (self._download_config, self.download_config),
+ (self._download_client_certificates,
+ self.download_client_certificate)
+ ]
+
+ return self.addCallbackChain(cb_chain)
diff --git a/src/leap/bitmask/services/eip/eipconfig.py b/src/leap/bitmask/services/eip/eipconfig.py
new file mode 100644
index 00000000..843e7397
--- /dev/null
+++ b/src/leap/bitmask/services/eip/eipconfig.py
@@ -0,0 +1,263 @@
+# -*- coding: utf-8 -*-
+# eipconfig.py
+# Copyright (C) 2013 LEAP
+#
+# 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/>.
+
+"""
+Provider configuration
+"""
+import logging
+import os
+import re
+import time
+
+import ipaddr
+
+from leap.bitmask.config.providerconfig import ProviderConfig
+from leap.bitmask.services.eip.eipspec import get_schema
+from leap.common.check import leap_assert, leap_assert_type
+from leap.common.config.baseconfig import BaseConfig
+
+logger = logging.getLogger(__name__)
+
+
+class VPNGatewaySelector(object):
+ """
+ VPN Gateway selector.
+ """
+ # http://www.timeanddate.com/time/map/
+ equivalent_timezones = {13: -11, 14: -10}
+
+ def __init__(self, eipconfig, tz_offset=None):
+ '''
+ Constructor for VPNGatewaySelector.
+
+ :param eipconfig: a valid EIP Configuration.
+ :type eipconfig: EIPConfig
+ :param tz_offset: use this offset as a local distance to GMT.
+ :type tz_offset: int
+ '''
+ leap_assert_type(eipconfig, EIPConfig)
+
+ 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
+
+ self._eipconfig = eipconfig
+
+ def get_gateways(self):
+ """
+ Returns the 4 best gateways, sorted by timezone proximity.
+
+ :rtype: list of IPv4Address or IPv6Address object.
+ """
+ gateways_timezones = []
+ locations = self._eipconfig.get_locations()
+ gateways = self._eipconfig.get_gateways()
+
+ for idx, gateway in enumerate(gateways):
+ gateway_location = gateway.get('location')
+ gateway_distance = 99 # if hasn't location -> should go last
+
+ if gateway_location is not None:
+ gw_offset = int(locations[gateway['location']]['timezone'])
+ if gw_offset in self.equivalent_timezones:
+ gw_offset = self.equivalent_timezones[gw_offset]
+
+ gateway_distance = self._get_timezone_distance(gw_offset)
+
+ ip = self._eipconfig.get_gateway_ip(idx)
+ gateways_timezones.append((ip, gateway_distance))
+
+ gateways_timezones = sorted(gateways_timezones,
+ key=lambda gw: gw[1])[:4]
+
+ gateways = [ip for ip, dist in gateways_timezones]
+ return gateways
+
+ def _get_timezone_distance(self, offset):
+ '''
+ Returns 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):
+ '''
+ Returns 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
+
+
+class EIPConfig(BaseConfig):
+ """
+ Provider configuration abstraction class
+ """
+ OPENVPN_ALLOWED_KEYS = ("auth", "cipher", "tls-cipher")
+ OPENVPN_CIPHERS_REGEX = re.compile("[A-Z0-9\-]+")
+
+ def __init__(self):
+ BaseConfig.__init__(self)
+ self._api_version = None
+
+ def _get_schema(self):
+ """
+ Returns the schema corresponding to the version given.
+
+ :rtype: dict or None if the version is not supported.
+ """
+ return get_schema(self._api_version)
+
+ def get_clusters(self):
+ # TODO: create an abstraction for clusters
+ return self._safe_get_value("clusters")
+
+ def get_gateways(self):
+ # TODO: create an abstraction for gateways
+ return self._safe_get_value("gateways")
+
+ def get_locations(self):
+ '''
+ Returns a list of locations
+
+ :rtype: dict
+ '''
+ return self._safe_get_value("locations")
+
+ def get_openvpn_configuration(self):
+ """
+ Returns a dictionary containing the openvpn configuration
+ parameters.
+
+ These are sanitized with alphanumeric whitelist.
+
+ :returns: openvpn configuration dict
+ :rtype: C{dict}
+ """
+ ovpncfg = self._safe_get_value("openvpn_configuration")
+ config = {}
+ for key, value in ovpncfg.items():
+ if key in self.OPENVPN_ALLOWED_KEYS and value is not None:
+ sanitized_val = self.OPENVPN_CIPHERS_REGEX.findall(value)
+ if len(sanitized_val) != 0:
+ _val = sanitized_val[0]
+ config[str(key)] = str(_val)
+ return config
+
+ def get_serial(self):
+ return self._safe_get_value("serial")
+
+ def get_version(self):
+ return self._safe_get_value("version")
+
+ def get_gateway_ip(self, index=0):
+ """
+ Returns the ip of the gateway.
+
+ :rtype: An IPv4Address or IPv6Address object.
+ """
+ gateways = self.get_gateways()
+ leap_assert(len(gateways) > 0, "We don't have any gateway!")
+ if index > len(gateways):
+ index = 0
+ logger.warning("Provided an unknown gateway index %s, " +
+ "defaulting to 0")
+ ip_addr_str = gateways[index]["ip_address"]
+
+ try:
+ ipaddr.IPAddress(ip_addr_str)
+ return ip_addr_str
+ except ValueError:
+ logger.error("Invalid ip address in config: %s" % (ip_addr_str,))
+ return None
+
+ def get_client_cert_path(self,
+ providerconfig=None,
+ about_to_download=False):
+ """
+ Returns the path to the certificate used by openvpn
+ """
+
+ leap_assert(providerconfig, "We need a provider")
+ leap_assert_type(providerconfig, ProviderConfig)
+
+ cert_path = os.path.join(self.get_path_prefix(),
+ "leap",
+ "providers",
+ providerconfig.get_domain(),
+ "keys",
+ "client",
+ "openvpn.pem")
+
+ if not about_to_download:
+ leap_assert(os.path.exists(cert_path),
+ "You need to download the certificate first")
+ logger.debug("Using OpenVPN cert %s" % (cert_path,))
+
+ return cert_path
+
+
+if __name__ == "__main__":
+ logger = logging.getLogger(name='leap')
+ logger.setLevel(logging.DEBUG)
+ console = logging.StreamHandler()
+ console.setLevel(logging.DEBUG)
+ formatter = logging.Formatter(
+ '%(asctime)s '
+ '- %(name)s - %(levelname)s - %(message)s')
+ console.setFormatter(formatter)
+ logger.addHandler(console)
+
+ eipconfig = EIPConfig('1')
+
+ try:
+ eipconfig.get_clusters()
+ except Exception as e:
+ assert isinstance(e, AssertionError), "Expected an assert"
+ print "Safe value getting is working"
+
+ if eipconfig.load("leap/providers/bitmask.net/eip-service.json"):
+ print eipconfig.get_clusters()
+ print eipconfig.get_gateways()
+ print eipconfig.get_locations()
+ print eipconfig.get_openvpn_configuration()
+ print eipconfig.get_serial()
+ print eipconfig.get_version()
diff --git a/src/leap/bitmask/services/eip/eipspec.py b/src/leap/bitmask/services/eip/eipspec.py
new file mode 100644
index 00000000..9cc56be3
--- /dev/null
+++ b/src/leap/bitmask/services/eip/eipspec.py
@@ -0,0 +1,85 @@
+# -*- coding: utf-8 -*-
+# eipspec.py
+# Copyright (C) 2013 LEAP
+#
+# 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/>.
+
+
+# Schemas dict
+# To add a schema for a version you should follow the form:
+# { '1': schema_v1, '2': schema_v2, ... etc }
+# so for instance, to add the '2' version, you should do:
+# eipservice_config_spec['2'] = schema_v2
+eipservice_config_spec = {}
+
+eipservice_config_spec['1'] = {
+ 'description': 'sample eip service config',
+ 'type': 'object',
+ 'properties': {
+ 'serial': {
+ 'type': int,
+ 'default': 1,
+ 'required': ["True"]
+ },
+ 'version': {
+ 'type': int,
+ 'default': 1,
+ 'required': ["True"]
+ },
+ 'clusters': {
+ 'type': list,
+ 'default': [
+ {"label": {
+ "en": "Location Unknown"},
+ "name": "location_unknown"}]
+ },
+ 'gateways': {
+ 'type': list,
+ 'default': [
+ {"capabilities": {
+ "adblock": True,
+ "filter_dns": True,
+ "ports": ["80", "53", "443", "1194"],
+ "protocols": ["udp", "tcp"],
+ "transport": ["openvpn"],
+ "user_ips": False},
+ "cluster": "location_unknown",
+ "host": "location.example.org",
+ "ip_address": "127.0.0.1"}]
+ },
+ 'locations': {
+ 'type': dict,
+ 'default': {}
+ },
+ 'openvpn_configuration': {
+ 'type': dict,
+ 'default': {
+ "auth": None,
+ "cipher": None,
+ "tls-cipher": None}
+ }
+ }
+}
+
+
+def get_schema(version):
+ """
+ Returns the schema corresponding to the version given.
+
+ :param version: the version of the schema to get.
+ :type version: str
+ :rtype: dict or None if the version is not supported.
+ """
+ schema = eipservice_config_spec.get(version, None)
+ return schema
diff --git a/src/leap/bitmask/services/eip/providerbootstrapper.py b/src/leap/bitmask/services/eip/providerbootstrapper.py
new file mode 100644
index 00000000..ac3a44db
--- /dev/null
+++ b/src/leap/bitmask/services/eip/providerbootstrapper.py
@@ -0,0 +1,340 @@
+# -*- coding: utf-8 -*-
+# providerbootstrapper.py
+# Copyright (C) 2013 LEAP
+#
+# 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/>.
+
+"""
+Provider bootstrapping
+"""
+import logging
+import socket
+import os
+
+import requests
+
+from PySide import QtCore
+
+from leap.bitmask.config.providerconfig import ProviderConfig, MissingCACert
+from leap.bitmask.util.request_helpers import get_content
+from leap.bitmask.util.constants import REQUEST_TIMEOUT
+from leap.bitmask.services.abstractbootstrapper import AbstractBootstrapper
+from leap.bitmask.provider.supportedapis import SupportedAPIs
+from leap.common.certs import get_digest
+from leap.common.files import check_and_fix_urw_only, get_mtime, mkdir_p
+from leap.common.check import leap_assert, leap_assert_type, leap_check
+
+
+logger = logging.getLogger(__name__)
+
+
+class UnsupportedProviderAPI(Exception):
+ """
+ Raised when attempting to use a provider with an incompatible API.
+ """
+ pass
+
+
+class WrongFingerprint(Exception):
+ """
+ Raised when a fingerprint comparison does not match.
+ """
+ pass
+
+
+class ProviderBootstrapper(AbstractBootstrapper):
+ """
+ Given a provider URL performs a series of checks and emits signals
+ after they are passed.
+ If a check fails, the subsequent checks are not executed
+ """
+
+ # All dicts returned are of the form
+ # {"passed": bool, "error": str}
+ name_resolution = QtCore.Signal(dict)
+ https_connection = QtCore.Signal(dict)
+ download_provider_info = QtCore.Signal(dict)
+
+ download_ca_cert = QtCore.Signal(dict)
+ check_ca_fingerprint = QtCore.Signal(dict)
+ check_api_certificate = QtCore.Signal(dict)
+
+ def __init__(self, bypass_checks=False):
+ """
+ Constructor for provider bootstrapper object
+
+ :param bypass_checks: Set to true if the app should bypass
+ first round of checks for CA certificates at bootstrap
+ :type bypass_checks: bool
+ """
+ AbstractBootstrapper.__init__(self, bypass_checks)
+
+ self._domain = None
+ self._provider_config = None
+ self._download_if_needed = False
+
+ def _check_name_resolution(self):
+ """
+ Checks that the name resolution for the provider name works
+ """
+ leap_assert(self._domain, "Cannot check DNS without a domain")
+
+ logger.debug("Checking name resolution for %s" % (self._domain))
+
+ # We don't skip this check, since it's basic for the whole
+ # system to work
+ socket.gethostbyname(self._domain)
+
+ def _check_https(self, *args):
+ """
+ Checks that https is working and that the provided certificate
+ checks out
+ """
+
+ leap_assert(self._domain, "Cannot check HTTPS without a domain")
+
+ logger.debug("Checking https for %s" % (self._domain))
+
+ # We don't skip this check, since it's basic for the whole
+ # system to work
+
+ try:
+ res = self._session.get("https://%s" % (self._domain,),
+ verify=not self._bypass_checks,
+ timeout=REQUEST_TIMEOUT)
+ res.raise_for_status()
+ except requests.exceptions.SSLError:
+ self._err_msg = self.tr("Provider certificate could "
+ "not be verified")
+ raise
+ except Exception:
+ self._err_msg = self.tr("Provider does not support HTTPS")
+ raise
+
+ def _download_provider_info(self, *args):
+ """
+ Downloads the provider.json defition
+ """
+ leap_assert(self._domain,
+ "Cannot download provider info without a domain")
+
+ logger.debug("Downloading provider info for %s" % (self._domain))
+
+ headers = {}
+
+ provider_json = os.path.join(
+ ProviderConfig().get_path_prefix(), "leap", "providers",
+ self._domain, "provider.json")
+ mtime = get_mtime(provider_json)
+
+ if self._download_if_needed and mtime:
+ headers['if-modified-since'] = mtime
+
+ uri = "https://%s/%s" % (self._domain, "provider.json")
+ verify = not self._bypass_checks
+
+ if mtime: # the provider.json exists
+ provider_config = ProviderConfig()
+ provider_config.load(provider_json)
+ try:
+ verify = provider_config.get_ca_cert_path()
+ uri = provider_config.get_api_uri() + '/provider.json'
+ except MissingCACert:
+ # get_ca_cert_path fails if the certificate does not exists.
+ pass
+
+ logger.debug("Requesting for provider.json... "
+ "uri: {0}, verify: {1}, headers: {2}".format(
+ uri, verify, headers))
+ res = self._session.get(uri, verify=verify,
+ headers=headers, timeout=REQUEST_TIMEOUT)
+ res.raise_for_status()
+ logger.debug("Request status code: {0}".format(res.status_code))
+
+ # Not modified
+ if res.status_code == 304:
+ logger.debug("Provider definition has not been modified")
+ else:
+ provider_definition, mtime = get_content(res)
+
+ provider_config = ProviderConfig()
+ provider_config.load(data=provider_definition, mtime=mtime)
+ provider_config.save(["leap",
+ "providers",
+ self._domain,
+ "provider.json"])
+
+ api_version = provider_config.get_api_version()
+ if SupportedAPIs.supports(api_version):
+ logger.debug("Provider definition has been modified")
+ else:
+ api_supported = ', '.join(SupportedAPIs.SUPPORTED_APIS)
+ error = ('Unsupported provider API version. '
+ 'Supported versions are: {}. '
+ 'Found: {}.').format(api_supported, api_version)
+
+ logger.error(error)
+ raise UnsupportedProviderAPI(error)
+
+ def run_provider_select_checks(self, domain, download_if_needed=False):
+ """
+ Populates the check queue.
+
+ :param domain: domain to check
+ :type domain: str
+
+ :param download_if_needed: if True, makes the checks do not
+ overwrite already downloaded data
+ :type download_if_needed: bool
+ """
+ leap_assert(domain and len(domain) > 0, "We need a domain!")
+
+ self._domain = ProviderConfig.sanitize_path_component(domain)
+ self._download_if_needed = download_if_needed
+
+ cb_chain = [
+ (self._check_name_resolution, self.name_resolution),
+ (self._check_https, self.https_connection),
+ (self._download_provider_info, self.download_provider_info)
+ ]
+
+ return self.addCallbackChain(cb_chain)
+
+ def _should_proceed_cert(self):
+ """
+ Returns False if the certificate already exists for the given
+ provider. True otherwise
+
+ :rtype: bool
+ """
+ leap_assert(self._provider_config, "We need a provider config!")
+
+ if not self._download_if_needed:
+ return True
+
+ return not os.path.exists(self._provider_config
+ .get_ca_cert_path(about_to_download=True))
+
+ def _download_ca_cert(self, *args):
+ """
+ Downloads the CA cert that is going to be used for the api URL
+ """
+
+ leap_assert(self._provider_config, "Cannot download the ca cert "
+ "without a provider config!")
+
+ logger.debug("Downloading ca cert for %s at %s" %
+ (self._domain, self._provider_config.get_ca_cert_uri()))
+
+ if not self._should_proceed_cert():
+ check_and_fix_urw_only(
+ self._provider_config
+ .get_ca_cert_path(about_to_download=True))
+ return
+
+ res = self._session.get(self._provider_config.get_ca_cert_uri(),
+ verify=not self._bypass_checks,
+ timeout=REQUEST_TIMEOUT)
+ res.raise_for_status()
+
+ cert_path = self._provider_config.get_ca_cert_path(
+ about_to_download=True)
+ cert_dir = os.path.dirname(cert_path)
+ mkdir_p(cert_dir)
+ with open(cert_path, "w") as f:
+ f.write(res.content)
+
+ check_and_fix_urw_only(cert_path)
+
+ def _check_ca_fingerprint(self, *args):
+ """
+ Checks the CA cert fingerprint against the one provided in the
+ json definition
+ """
+ leap_assert(self._provider_config, "Cannot check the ca cert "
+ "without a provider config!")
+
+ logger.debug("Checking ca fingerprint for %s and cert %s" %
+ (self._domain,
+ self._provider_config.get_ca_cert_path()))
+
+ if not self._should_proceed_cert():
+ return
+
+ parts = self._provider_config.get_ca_cert_fingerprint().split(":")
+
+ error_msg = "Wrong fingerprint format"
+ leap_check(len(parts) == 2, error_msg, WrongFingerprint)
+
+ method = parts[0].strip()
+ fingerprint = parts[1].strip()
+ cert_data = None
+ with open(self._provider_config.get_ca_cert_path()) as f:
+ cert_data = f.read()
+
+ leap_assert(len(cert_data) > 0, "Could not read certificate data")
+ digest = get_digest(cert_data, method)
+
+ error_msg = "Downloaded certificate has a different fingerprint!"
+ leap_check(digest == fingerprint, error_msg, WrongFingerprint)
+
+ def _check_api_certificate(self, *args):
+ """
+ Tries to make an API call with the downloaded cert and checks
+ if it validates against it
+ """
+ leap_assert(self._provider_config, "Cannot check the ca cert "
+ "without a provider config!")
+
+ logger.debug("Checking api certificate for %s and cert %s" %
+ (self._provider_config.get_api_uri(),
+ self._provider_config.get_ca_cert_path()))
+
+ if not self._should_proceed_cert():
+ return
+
+ test_uri = "%s/%s/cert" % (self._provider_config.get_api_uri(),
+ self._provider_config.get_api_version())
+ res = self._session.get(test_uri,
+ verify=self._provider_config
+ .get_ca_cert_path(),
+ timeout=REQUEST_TIMEOUT)
+ res.raise_for_status()
+
+ def run_provider_setup_checks(self,
+ provider_config,
+ download_if_needed=False):
+ """
+ Starts the checks needed for a new provider setup.
+
+ :param provider_config: Provider configuration
+ :type provider_config: ProviderConfig
+
+ :param download_if_needed: if True, makes the checks do not
+ overwrite already downloaded data.
+ :type download_if_needed: bool
+ """
+ leap_assert(provider_config, "We need a provider config!")
+ leap_assert_type(provider_config, ProviderConfig)
+
+ self._provider_config = provider_config
+ self._download_if_needed = download_if_needed
+
+ cb_chain = [
+ (self._download_ca_cert, self.download_ca_cert),
+ (self._check_ca_fingerprint, self.check_ca_fingerprint),
+ (self._check_api_certificate, self.check_api_certificate)
+ ]
+
+ return self.addCallbackChain(cb_chain)
diff --git a/src/leap/bitmask/services/eip/tests/__init__.py b/src/leap/bitmask/services/eip/tests/__init__.py
new file mode 100644
index 00000000..e69de29b
--- /dev/null
+++ b/src/leap/bitmask/services/eip/tests/__init__.py
diff --git a/src/leap/bitmask/services/eip/tests/test_eipbootstrapper.py b/src/leap/bitmask/services/eip/tests/test_eipbootstrapper.py
new file mode 100644
index 00000000..d0d78eed
--- /dev/null
+++ b/src/leap/bitmask/services/eip/tests/test_eipbootstrapper.py
@@ -0,0 +1,347 @@
+# -*- coding: utf-8 -*-
+# test_eipbootstrapper.py
+# Copyright (C) 2013 LEAP
+#
+# 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 the EIP Boostrapper checks
+
+These will be whitebox tests since we want to make sure the private
+implementation is checking what we expect.
+"""
+
+import os
+import mock
+import tempfile
+import time
+try:
+ import unittest2 as unittest
+except ImportError:
+ import unittest
+
+from nose.twistedtools import deferred, reactor
+from twisted.internet import threads
+from requests.models import Response
+
+from leap.bitmask.services.eip.eipbootstrapper import EIPBootstrapper
+from leap.bitmask.services.eip.eipconfig import EIPConfig
+from leap.bitmask.config.providerconfig import ProviderConfig
+from leap.bitmask.crypto.tests import fake_provider
+from leap.bitmask.crypto.srpauth import SRPAuth
+from leap.common.testing.basetest import BaseLeapTest
+from leap.common.files import mkdir_p
+
+
+class EIPBootstrapperActiveTest(BaseLeapTest):
+ @classmethod
+ def setUpClass(cls):
+ BaseLeapTest.setUpClass()
+ factory = fake_provider.get_provider_factory()
+ http = reactor.listenTCP(0, factory)
+ https = reactor.listenSSL(
+ 0, factory,
+ fake_provider.OpenSSLServerContextFactory())
+ get_port = lambda p: p.getHost().port
+ cls.http_port = get_port(http)
+ cls.https_port = get_port(https)
+
+ def setUp(self):
+ self.eb = EIPBootstrapper()
+ self.old_pp = EIPConfig.get_path_prefix
+ self.old_save = EIPConfig.save
+ self.old_load = EIPConfig.load
+ self.old_si = SRPAuth.get_session_id
+
+ def tearDown(self):
+ EIPConfig.get_path_prefix = self.old_pp
+ EIPConfig.save = self.old_save
+ EIPConfig.load = self.old_load
+ SRPAuth.get_session_id = self.old_si
+
+ def _download_config_test_template(self, ifneeded, new):
+ """
+ All download config tests have the same structure, so this is
+ a parametrized test for that.
+
+ :param ifneeded: sets _download_if_needed
+ :type ifneeded: bool
+ :param new: if True uses time.time() as mtime for the mocked
+ eip-service file, otherwise it uses 100 (a really
+ old mtime)
+ :type new: float or int (will be coersed)
+ """
+ pc = ProviderConfig()
+ pc.get_domain = mock.MagicMock(
+ return_value="localhost:%s" % (self.https_port))
+ self.eb._provider_config = pc
+
+ pc.get_api_uri = mock.MagicMock(
+ return_value="https://%s" % (pc.get_domain()))
+ pc.get_api_version = mock.MagicMock(return_value="1")
+
+ # This is to ignore https checking, since it's not the point
+ # of this test
+ pc.get_ca_cert_path = mock.MagicMock(return_value=False)
+
+ path_prefix = tempfile.mkdtemp()
+ EIPConfig.get_path_prefix = mock.MagicMock(return_value=path_prefix)
+ EIPConfig.save = mock.MagicMock()
+ EIPConfig.load = mock.MagicMock()
+
+ self.eb._download_if_needed = ifneeded
+
+ provider_dir = os.path.join(EIPConfig.get_path_prefix(),
+ "leap",
+ "providers",
+ pc.get_domain())
+ mkdir_p(provider_dir)
+ eip_config_path = os.path.join(provider_dir,
+ "eip-service.json")
+
+ with open(eip_config_path, "w") as ec:
+ ec.write("A")
+
+ # set mtime to something really new
+ if new:
+ os.utime(eip_config_path, (-1, time.time()))
+ else:
+ os.utime(eip_config_path, (-1, 100))
+
+ @deferred()
+ def test_download_config_not_modified(self):
+ self._download_config_test_template(True, True)
+
+ d = threads.deferToThread(self.eb._download_config)
+
+ def check(*args):
+ self.assertFalse(self.eb._eip_config.save.called)
+ d.addCallback(check)
+ return d
+
+ @deferred()
+ def test_download_config_modified(self):
+ self._download_config_test_template(True, False)
+
+ d = threads.deferToThread(self.eb._download_config)
+
+ def check(*args):
+ self.assertTrue(self.eb._eip_config.save.called)
+ d.addCallback(check)
+ return d
+
+ @deferred()
+ def test_download_config_ignores_mtime(self):
+ self._download_config_test_template(False, True)
+
+ d = threads.deferToThread(self.eb._download_config)
+
+ def check(*args):
+ self.eb._eip_config.save.assert_called_once_with(
+ ["leap",
+ "providers",
+ self.eb._provider_config.get_domain(),
+ "eip-service.json"])
+ d.addCallback(check)
+ return d
+
+ def _download_certificate_test_template(self, ifneeded, createcert):
+ """
+ All download client certificate tests have the same structure,
+ so this is a parametrized test for that.
+
+ :param ifneeded: sets _download_if_needed
+ :type ifneeded: bool
+ :param createcert: if True it creates a dummy file to play the
+ part of a downloaded certificate
+ :type createcert: bool
+
+ :returns: the temp eip cert path and the dummy cert contents
+ :rtype: tuple of str, str
+ """
+ pc = ProviderConfig()
+ ec = EIPConfig()
+ self.eb._provider_config = pc
+ self.eb._eip_config = ec
+
+ pc.get_domain = mock.MagicMock(
+ return_value="localhost:%s" % (self.https_port))
+ pc.get_api_uri = mock.MagicMock(
+ return_value="https://%s" % (pc.get_domain()))
+ pc.get_api_version = mock.MagicMock(return_value="1")
+ pc.get_ca_cert_path = mock.MagicMock(return_value=False)
+
+ path_prefix = tempfile.mkdtemp()
+ EIPConfig.get_path_prefix = mock.MagicMock(return_value=path_prefix)
+ EIPConfig.save = mock.MagicMock()
+ EIPConfig.load = mock.MagicMock()
+
+ self.eb._download_if_needed = ifneeded
+
+ provider_dir = os.path.join(EIPConfig.get_path_prefix(),
+ "leap",
+ "providers",
+ "somedomain")
+ mkdir_p(provider_dir)
+ eip_cert_path = os.path.join(provider_dir,
+ "cert")
+
+ ec.get_client_cert_path = mock.MagicMock(
+ return_value=eip_cert_path)
+
+ cert_content = "A"
+ if createcert:
+ with open(eip_cert_path, "w") as ec:
+ ec.write(cert_content)
+
+ return eip_cert_path, cert_content
+
+ def test_download_client_certificate_not_modified(self):
+ cert_path, old_cert_content = self._download_certificate_test_template(
+ True, True)
+
+ with mock.patch('leap.common.certs.should_redownload',
+ new_callable=mock.MagicMock,
+ return_value=False):
+ self.eb._download_client_certificates()
+ with open(cert_path, "r") as c:
+ self.assertEqual(c.read(), old_cert_content)
+
+ @deferred()
+ def test_download_client_certificate_old_cert(self):
+ cert_path, old_cert_content = self._download_certificate_test_template(
+ True, True)
+
+ def wrapper(*args):
+ with mock.patch('leap.common.certs.should_redownload',
+ new_callable=mock.MagicMock,
+ return_value=True):
+ with mock.patch('leap.common.certs.is_valid_pemfile',
+ new_callable=mock.MagicMock,
+ return_value=True):
+ self.eb._download_client_certificates()
+
+ def check(*args):
+ with open(cert_path, "r") as c:
+ self.assertNotEqual(c.read(), old_cert_content)
+ d = threads.deferToThread(wrapper)
+ d.addCallback(check)
+
+ return d
+
+ @deferred()
+ def test_download_client_certificate_no_cert(self):
+ cert_path, _ = self._download_certificate_test_template(
+ True, False)
+
+ def wrapper(*args):
+ with mock.patch('leap.common.certs.should_redownload',
+ new_callable=mock.MagicMock,
+ return_value=False):
+ with mock.patch('leap.common.certs.is_valid_pemfile',
+ new_callable=mock.MagicMock,
+ return_value=True):
+ self.eb._download_client_certificates()
+
+ def check(*args):
+ self.assertTrue(os.path.exists(cert_path))
+ d = threads.deferToThread(wrapper)
+ d.addCallback(check)
+
+ return d
+
+ @deferred()
+ def test_download_client_certificate_force_not_valid(self):
+ cert_path, old_cert_content = self._download_certificate_test_template(
+ True, True)
+
+ def wrapper(*args):
+ with mock.patch('leap.common.certs.should_redownload',
+ new_callable=mock.MagicMock,
+ return_value=True):
+ with mock.patch('leap.common.certs.is_valid_pemfile',
+ new_callable=mock.MagicMock,
+ return_value=True):
+ self.eb._download_client_certificates()
+
+ def check(*args):
+ with open(cert_path, "r") as c:
+ self.assertNotEqual(c.read(), old_cert_content)
+ d = threads.deferToThread(wrapper)
+ d.addCallback(check)
+
+ return d
+
+ @deferred()
+ def test_download_client_certificate_invalid_download(self):
+ cert_path, _ = self._download_certificate_test_template(
+ False, False)
+
+ def wrapper(*args):
+ with mock.patch('leap.common.certs.should_redownload',
+ new_callable=mock.MagicMock,
+ return_value=True):
+ with mock.patch('leap.common.certs.is_valid_pemfile',
+ new_callable=mock.MagicMock,
+ return_value=False):
+ with self.assertRaises(Exception):
+ self.eb._download_client_certificates()
+ d = threads.deferToThread(wrapper)
+
+ return d
+
+ @deferred()
+ def test_download_client_certificate_uses_session_id(self):
+ _, _ = self._download_certificate_test_template(
+ False, False)
+
+ SRPAuth.get_session_id = mock.MagicMock(return_value="1")
+
+ def check_cookie(*args, **kwargs):
+ cookies = kwargs.get("cookies", None)
+ self.assertEqual(cookies, {'_session_id': '1'})
+ return Response()
+
+ def wrapper(*args):
+ with mock.patch('leap.common.certs.should_redownload',
+ new_callable=mock.MagicMock,
+ return_value=False):
+ with mock.patch('leap.common.certs.is_valid_pemfile',
+ new_callable=mock.MagicMock,
+ return_value=True):
+ with mock.patch('requests.sessions.Session.get',
+ new_callable=mock.MagicMock,
+ side_effect=check_cookie):
+ with mock.patch('requests.models.Response.content',
+ new_callable=mock.PropertyMock,
+ return_value="A"):
+ self.eb._download_client_certificates()
+
+ d = threads.deferToThread(wrapper)
+
+ return d
+
+ @deferred()
+ def test_run_eip_setup_checks(self):
+ self.eb._download_config = mock.MagicMock()
+ self.eb._download_client_certificates = mock.MagicMock()
+
+ d = self.eb.run_eip_setup_checks(ProviderConfig())
+
+ def check(*args):
+ self.eb._download_config.assert_called_once_with()
+ self.eb._download_client_certificates.assert_called_once_with(None)
+ d.addCallback(check)
+ return d
diff --git a/src/leap/bitmask/services/eip/tests/test_eipconfig.py b/src/leap/bitmask/services/eip/tests/test_eipconfig.py
new file mode 100644
index 00000000..f8489e07
--- /dev/null
+++ b/src/leap/bitmask/services/eip/tests/test_eipconfig.py
@@ -0,0 +1,324 @@
+# -*- coding: utf-8 -*-
+# test_eipconfig.py
+# Copyright (C) 2013 LEAP
+#
+# 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 eipconfig
+"""
+import copy
+import json
+import os
+import unittest
+
+from leap.bitmask.services.eip.eipconfig import EIPConfig
+from leap.bitmask.config.providerconfig import ProviderConfig
+from leap.common.testing.basetest import BaseLeapTest
+
+from mock import Mock
+
+
+sample_config = {
+ "gateways": [
+ {
+ "capabilities": {
+ "adblock": False,
+ "filter_dns": True,
+ "limited": True,
+ "ports": [
+ "1194",
+ "443",
+ "53",
+ "80"],
+ "protocols": [
+ "tcp",
+ "udp"],
+ "transport": ["openvpn"],
+ "user_ips": False},
+ "host": "host.dev.example.org",
+ "ip_address": "11.22.33.44",
+ "location": "cyberspace"
+ }, {
+ "capabilities": {
+ "adblock": False,
+ "filter_dns": True,
+ "limited": True,
+ "ports": [
+ "1194",
+ "443",
+ "53",
+ "80"],
+ "protocols": [
+ "tcp",
+ "udp"],
+ "transport": ["openvpn"],
+ "user_ips": False},
+ "host": "host2.dev.example.org",
+ "ip_address": "22.33.44.55",
+ "location": "cyberspace"
+ }
+ ],
+ "locations": {
+ "ankara": {
+ "country_code": "XX",
+ "hemisphere": "S",
+ "name": "Antarctica",
+ "timezone": "+2"
+ },
+ "cyberspace": {
+ "country_code": "XX",
+ "hemisphere": "X",
+ "name": "outer space",
+ "timezone": ""
+ }
+ },
+ "openvpn_configuration": {
+ "auth": "SHA1",
+ "cipher": "AES-128-CBC",
+ "tls-cipher": "DHE-RSA-AES128-SHA"
+ },
+ "serial": 1,
+ "version": 1
+}
+
+
+class EIPConfigTest(BaseLeapTest):
+
+ __name__ = "eip_config_tests"
+
+ maxDiff = None
+
+ def setUp(self):
+ self._old_ospath_exists = os.path.exists
+
+ def tearDown(self):
+ os.path.exists = self._old_ospath_exists
+
+ def _write_config(self, data):
+ """
+ Helper to write some data to a temp config file.
+
+ :param data: data to be used to save in the config file.
+ :data type: dict (valid json)
+ """
+ self.configfile = os.path.join(self.tempdir, "eipconfig.json")
+ conf = open(self.configfile, "w")
+ conf.write(json.dumps(data))
+ conf.close()
+
+ def _get_eipconfig(self, fromfile=True, data=sample_config, api_ver='1'):
+ """
+ Helper that returns an EIPConfig object using the data parameter
+ or a sample data.
+
+ :param fromfile: sets if we should use a file or a string
+ :type fromfile: bool
+ :param data: sets the data to be used to load in the EIPConfig object
+ :type data: dict (valid json)
+ :param api_ver: the api_version schema to use.
+ :type api_ver: str
+ :rtype: EIPConfig
+ """
+ config = EIPConfig()
+ config.set_api_version(api_ver)
+
+ loaded = False
+ if fromfile:
+ self._write_config(data)
+ loaded = config.load(self.configfile, relative=False)
+ else:
+ json_string = json.dumps(data)
+ loaded = config.load(data=json_string)
+
+ if not loaded:
+ return None
+
+ return config
+
+ def test_loads_from_file(self):
+ config = self._get_eipconfig()
+ self.assertIsNotNone(config)
+
+ def test_loads_from_data(self):
+ config = self._get_eipconfig(fromfile=False)
+ self.assertIsNotNone(config)
+
+ def test_load_valid_config_from_file(self):
+ config = self._get_eipconfig()
+ self.assertIsNotNone(config)
+
+ self.assertEqual(
+ config.get_openvpn_configuration(),
+ sample_config["openvpn_configuration"])
+
+ sample_ip = sample_config["gateways"][0]["ip_address"]
+ self.assertEqual(
+ config.get_gateway_ip(),
+ sample_ip)
+ self.assertEqual(config.get_version(), sample_config["version"])
+ self.assertEqual(config.get_serial(), sample_config["serial"])
+ self.assertEqual(config.get_gateways(), sample_config["gateways"])
+ self.assertEqual(config.get_locations(), sample_config["locations"])
+ self.assertEqual(config.get_clusters(), None)
+
+ def test_load_valid_config_from_data(self):
+ config = self._get_eipconfig(fromfile=False)
+ self.assertIsNotNone(config)
+
+ self.assertEqual(
+ config.get_openvpn_configuration(),
+ sample_config["openvpn_configuration"])
+
+ sample_ip = sample_config["gateways"][0]["ip_address"]
+ self.assertEqual(
+ config.get_gateway_ip(),
+ sample_ip)
+
+ self.assertEqual(config.get_version(), sample_config["version"])
+ self.assertEqual(config.get_serial(), sample_config["serial"])
+ self.assertEqual(config.get_gateways(), sample_config["gateways"])
+ self.assertEqual(config.get_locations(), sample_config["locations"])
+ self.assertEqual(config.get_clusters(), None)
+
+ def test_sanitize_extra_parameters(self):
+ data = copy.deepcopy(sample_config)
+ data['openvpn_configuration']["extra_param"] = "FOO"
+ config = self._get_eipconfig(data=data)
+
+ self.assertEqual(
+ config.get_openvpn_configuration(),
+ sample_config["openvpn_configuration"])
+
+ def test_sanitize_non_allowed_chars(self):
+ data = copy.deepcopy(sample_config)
+ data['openvpn_configuration']["auth"] = "SHA1;"
+ config = self._get_eipconfig(data=data)
+
+ self.assertEqual(
+ config.get_openvpn_configuration(),
+ sample_config["openvpn_configuration"])
+
+ data = copy.deepcopy(sample_config)
+ data['openvpn_configuration']["auth"] = "SHA1>`&|"
+ config = self._get_eipconfig(data=data)
+
+ self.assertEqual(
+ config.get_openvpn_configuration(),
+ sample_config["openvpn_configuration"])
+
+ def test_sanitize_lowercase(self):
+ data = copy.deepcopy(sample_config)
+ data['openvpn_configuration']["auth"] = "shaSHA1"
+ config = self._get_eipconfig(data=data)
+
+ self.assertEqual(
+ config.get_openvpn_configuration(),
+ sample_config["openvpn_configuration"])
+
+ def test_all_characters_invalid(self):
+ data = copy.deepcopy(sample_config)
+ data['openvpn_configuration']["auth"] = "sha&*!@#;"
+ config = self._get_eipconfig(data=data)
+
+ self.assertEqual(
+ config.get_openvpn_configuration(),
+ {'cipher': 'AES-128-CBC',
+ 'tls-cipher': 'DHE-RSA-AES128-SHA'})
+
+ def test_sanitize_bad_ip(self):
+ data = copy.deepcopy(sample_config)
+ data['gateways'][0]["ip_address"] = "11.22.33.44;"
+ config = self._get_eipconfig(data=data)
+
+ self.assertEqual(config.get_gateway_ip(), None)
+
+ data = copy.deepcopy(sample_config)
+ data['gateways'][0]["ip_address"] = "11.22.33.44`"
+ config = self._get_eipconfig(data=data)
+
+ self.assertEqual(config.get_gateway_ip(), None)
+
+ def test_default_gateway_on_unknown_index(self):
+ config = self._get_eipconfig()
+ sample_ip = sample_config["gateways"][0]["ip_address"]
+ self.assertEqual(config.get_gateway_ip(999), sample_ip)
+
+ def test_get_gateway_by_index(self):
+ config = self._get_eipconfig()
+ sample_ip_0 = sample_config["gateways"][0]["ip_address"]
+ sample_ip_1 = sample_config["gateways"][1]["ip_address"]
+ self.assertEqual(config.get_gateway_ip(0), sample_ip_0)
+ self.assertEqual(config.get_gateway_ip(1), sample_ip_1)
+
+ def test_get_client_cert_path_as_expected(self):
+ config = self._get_eipconfig()
+ config.get_path_prefix = Mock(return_value='test')
+
+ provider_config = ProviderConfig()
+
+ # mock 'get_domain' so we don't need to load a config
+ provider_domain = 'test.provider.com'
+ provider_config.get_domain = Mock(return_value=provider_domain)
+
+ expected_path = os.path.join('test', 'leap', 'providers',
+ provider_domain, 'keys', 'client',
+ 'openvpn.pem')
+
+ # mock 'os.path.exists' so we don't get an error for unexisting file
+ os.path.exists = Mock(return_value=True)
+ cert_path = config.get_client_cert_path(provider_config)
+
+ self.assertEqual(cert_path, expected_path)
+
+ def test_get_client_cert_path_about_to_download(self):
+ config = self._get_eipconfig()
+ config.get_path_prefix = Mock(return_value='test')
+
+ provider_config = ProviderConfig()
+
+ # mock 'get_domain' so we don't need to load a config
+ provider_domain = 'test.provider.com'
+ provider_config.get_domain = Mock(return_value=provider_domain)
+
+ expected_path = os.path.join('test', 'leap', 'providers',
+ provider_domain, 'keys', 'client',
+ 'openvpn.pem')
+
+ cert_path = config.get_client_cert_path(
+ provider_config, about_to_download=True)
+
+ self.assertEqual(cert_path, expected_path)
+
+ def test_get_client_cert_path_fails(self):
+ config = self._get_eipconfig()
+ provider_config = ProviderConfig()
+
+ # mock 'get_domain' so we don't need to load a config
+ provider_domain = 'test.provider.com'
+ provider_config.get_domain = Mock(return_value=provider_domain)
+
+ with self.assertRaises(AssertionError):
+ config.get_client_cert_path(provider_config)
+
+ def test_fails_without_api_set(self):
+ config = EIPConfig()
+ with self.assertRaises(AssertionError):
+ config.load('non-relevant-path')
+
+ def test_fails_with_api_without_schema(self):
+ with self.assertRaises(AssertionError):
+ self._get_eipconfig(api_ver='123')
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/src/leap/bitmask/services/eip/tests/test_providerbootstrapper.py b/src/leap/bitmask/services/eip/tests/test_providerbootstrapper.py
new file mode 100644
index 00000000..96ab53ce
--- /dev/null
+++ b/src/leap/bitmask/services/eip/tests/test_providerbootstrapper.py
@@ -0,0 +1,532 @@
+# -*- coding: utf-8 -*-
+# test_providerbootstrapper.py
+# Copyright (C) 2013 LEAP
+#
+# 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 the Provider Boostrapper checks
+
+These will be whitebox tests since we want to make sure the private
+implementation is checking what we expect.
+"""
+
+import os
+import mock
+import socket
+import stat
+import tempfile
+import time
+import requests
+try:
+ import unittest2 as unittest
+except ImportError:
+ import unittest
+
+from nose.twistedtools import deferred, reactor
+from twisted.internet import threads
+from requests.models import Response
+
+from leap.bitmask.services.eip.providerbootstrapper import ProviderBootstrapper
+from leap.bitmask.services.eip.providerbootstrapper import \
+ UnsupportedProviderAPI
+from leap.bitmask.services.eip.providerbootstrapper import WrongFingerprint
+from leap.bitmask.provider.supportedapis import SupportedAPIs
+from leap.bitmask.config.providerconfig import ProviderConfig
+from leap.bitmask.crypto.tests import fake_provider
+from leap.common.files import mkdir_p
+from leap.common.testing.https_server import where
+from leap.common.testing.basetest import BaseLeapTest
+
+
+class ProviderBootstrapperTest(BaseLeapTest):
+ def setUp(self):
+ self.pb = ProviderBootstrapper()
+
+ def tearDown(self):
+ pass
+
+ def test_name_resolution_check(self):
+ # Something highly likely to success
+ self.pb._domain = "google.com"
+ self.pb._check_name_resolution()
+ # Something highly likely to fail
+ self.pb._domain = "uquhqweuihowquie.abc.def"
+ with self.assertRaises(socket.gaierror):
+ self.pb._check_name_resolution()
+
+ @deferred()
+ def test_run_provider_select_checks(self):
+ self.pb._check_name_resolution = mock.MagicMock()
+ self.pb._check_https = mock.MagicMock()
+ self.pb._download_provider_info = mock.MagicMock()
+
+ d = self.pb.run_provider_select_checks("somedomain")
+
+ def check(*args):
+ self.pb._check_name_resolution.assert_called_once_with()
+ self.pb._check_https.assert_called_once_with(None)
+ self.pb._download_provider_info.assert_called_once_with(None)
+ d.addCallback(check)
+ return d
+
+ @deferred()
+ def test_run_provider_setup_checks(self):
+ self.pb._download_ca_cert = mock.MagicMock()
+ self.pb._check_ca_fingerprint = mock.MagicMock()
+ self.pb._check_api_certificate = mock.MagicMock()
+
+ d = self.pb.run_provider_setup_checks(ProviderConfig())
+
+ def check(*args):
+ self.pb._download_ca_cert.assert_called_once_with()
+ self.pb._check_ca_fingerprint.assert_called_once_with(None)
+ self.pb._check_api_certificate.assert_called_once_with(None)
+ d.addCallback(check)
+ return d
+
+ def test_should_proceed_cert(self):
+ self.pb._provider_config = mock.Mock()
+ self.pb._provider_config.get_ca_cert_path = mock.MagicMock(
+ return_value=where("cacert.pem"))
+
+ self.pb._download_if_needed = False
+ self.assertTrue(self.pb._should_proceed_cert())
+
+ self.pb._download_if_needed = True
+ self.assertFalse(self.pb._should_proceed_cert())
+
+ self.pb._provider_config.get_ca_cert_path = mock.MagicMock(
+ return_value=where("somefilethatdoesntexist.pem"))
+ self.assertTrue(self.pb._should_proceed_cert())
+
+ def _check_download_ca_cert(self, should_proceed):
+ """
+ Helper to check different paths easily for the download ca
+ cert check
+
+ :param should_proceed: sets the _should_proceed_cert in the
+ provider bootstrapper being tested
+ :type should_proceed: bool
+
+ :returns: The contents of the certificate, the expected
+ content depending on should_proceed, and the mode of
+ the file to be checked by the caller
+ :rtype: tuple of str, str, int
+ """
+ old_content = "NOT THE NEW CERT"
+ new_content = "NEW CERT"
+ new_cert_path = os.path.join(tempfile.mkdtemp(),
+ "mynewcert.pem")
+
+ with open(new_cert_path, "w") as c:
+ c.write(old_content)
+
+ self.pb._provider_config = mock.Mock()
+ self.pb._provider_config.get_ca_cert_path = mock.MagicMock(
+ return_value=new_cert_path)
+ self.pb._domain = "somedomain"
+
+ self.pb._should_proceed_cert = mock.MagicMock(
+ return_value=should_proceed)
+
+ read = None
+ content_to_check = None
+ mode = None
+
+ with mock.patch('requests.models.Response.content',
+ new_callable=mock.PropertyMock) as \
+ content:
+ content.return_value = new_content
+ response_obj = Response()
+ response_obj.raise_for_status = mock.MagicMock()
+
+ self.pb._session.get = mock.MagicMock(return_value=response_obj)
+ self.pb._download_ca_cert()
+ with open(new_cert_path, "r") as nc:
+ read = nc.read()
+ if should_proceed:
+ content_to_check = new_content
+ else:
+ content_to_check = old_content
+ mode = stat.S_IMODE(os.stat(new_cert_path).st_mode)
+
+ os.unlink(new_cert_path)
+ return read, content_to_check, mode
+
+ def test_download_ca_cert_no_saving(self):
+ read, expected_read, mode = self._check_download_ca_cert(False)
+ self.assertEqual(read, expected_read)
+ self.assertEqual(mode, int("600", 8))
+
+ def test_download_ca_cert_saving(self):
+ read, expected_read, mode = self._check_download_ca_cert(True)
+ self.assertEqual(read, expected_read)
+ self.assertEqual(mode, int("600", 8))
+
+ def test_check_ca_fingerprint_skips(self):
+ self.pb._provider_config = mock.Mock()
+ self.pb._provider_config.get_ca_cert_fingerprint = mock.MagicMock(
+ return_value="")
+ self.pb._domain = "somedomain"
+
+ self.pb._should_proceed_cert = mock.MagicMock(return_value=False)
+
+ self.pb._check_ca_fingerprint()
+ self.assertFalse(self.pb._provider_config.
+ get_ca_cert_fingerprint.called)
+
+ def test_check_ca_cert_fingerprint_raises_bad_format(self):
+ self.pb._provider_config = mock.Mock()
+ self.pb._provider_config.get_ca_cert_fingerprint = mock.MagicMock(
+ return_value="wrongfprformat!!")
+ self.pb._domain = "somedomain"
+
+ self.pb._should_proceed_cert = mock.MagicMock(return_value=True)
+
+ with self.assertRaises(WrongFingerprint):
+ self.pb._check_ca_fingerprint()
+
+ # This two hashes different in the last byte, but that's good enough
+ # for the tests
+ KNOWN_BAD_HASH = "SHA256: 0f17c033115f6b76ff67871872303ff65034efe" \
+ "7dd1b910062ca323eb4da5c7f"
+ KNOWN_GOOD_HASH = "SHA256: 0f17c033115f6b76ff67871872303ff65034ef" \
+ "e7dd1b910062ca323eb4da5c7e"
+ KNOWN_GOOD_CERT = """
+-----BEGIN CERTIFICATE-----
+MIIFbzCCA1egAwIBAgIBATANBgkqhkiG9w0BAQ0FADBKMRgwFgYDVQQDDA9CaXRt
+YXNrIFJvb3QgQ0ExEDAOBgNVBAoMB0JpdG1hc2sxHDAaBgNVBAsME2h0dHBzOi8v
+Yml0bWFzay5uZXQwHhcNMTIxMTA2MDAwMDAwWhcNMjIxMTA2MDAwMDAwWjBKMRgw
+FgYDVQQDDA9CaXRtYXNrIFJvb3QgQ0ExEDAOBgNVBAoMB0JpdG1hc2sxHDAaBgNV
+BAsME2h0dHBzOi8vYml0bWFzay5uZXQwggIiMA0GCSqGSIb3DQEBAQUAA4ICDwAw
+ggIKAoICAQC1eV4YvayaU+maJbWrD4OHo3d7S1BtDlcvkIRS1Fw3iYDjsyDkZxai
+dHp4EUasfNQ+EVtXUvtk6170EmLco6Elg8SJBQ27trE6nielPRPCfX3fQzETRfvB
+7tNvGw4Jn2YKiYoMD79kkjgyZjkJ2r/bEHUSevmR09BRp86syHZerdNGpXYhcQ84
+CA1+V+603GFIHnrP+uQDdssW93rgDNYu+exT+Wj6STfnUkugyjmPRPjL7wh0tzy+
+znCeLl4xiV3g9sjPnc7r2EQKd5uaTe3j71sDPF92KRk0SSUndREz+B1+Dbe/RGk4
+MEqGFuOzrtsgEhPIX0hplhb0Tgz/rtug+yTT7oJjBa3u20AAOQ38/M99EfdeJvc4
+lPFF1XBBLh6X9UKF72an2NuANiX6XPySnJgZ7nZ09RiYZqVwu/qt3DfvLfhboq+0
+bQvLUPXrVDr70onv5UDjpmEA/cLmaIqqrduuTkFZOym65/PfAPvpGnt7crQj/Ibl
+DEDYZQmP7AS+6zBjoOzNjUGE5r40zWAR1RSi7zliXTu+yfsjXUIhUAWmYR6J3KxB
+lfsiHBQ+8dn9kC3YrUexWoOqBiqJOAJzZh5Y1tqgzfh+2nmHSB2dsQRs7rDRRlyy
+YMbkpzL9ZsOUO2eTP1mmar6YjCN+rggYjRrX71K2SpBG6b1zZxOG+wIDAQABo2Aw
+XjAdBgNVHQ4EFgQUuYGDLL2sswnYpHHvProt1JU+D48wDgYDVR0PAQH/BAQDAgIE
+MAwGA1UdEwQFMAMBAf8wHwYDVR0jBBgwFoAUuYGDLL2sswnYpHHvProt1JU+D48w
+DQYJKoZIhvcNAQENBQADggIBADeG67vaFcbITGpi51264kHPYPEWaXUa5XYbtmBl
+cXYyB6hY5hv/YNuVGJ1gWsDmdeXEyj0j2icGQjYdHRfwhrbEri+h1EZOm1cSBDuY
+k/P5+ctHyOXx8IE79DBsZ6IL61UKIaKhqZBfLGYcWu17DVV6+LT+AKtHhOrv3TSj
+RnAcKnCbKqXLhUPXpK0eTjPYS2zQGQGIhIy9sQXVXJJJsGrPgMxna1Xw2JikBOCG
+htD/JKwt6xBmNwktH0GI/LVtVgSp82Clbn9C4eZN9E5YbVYjLkIEDhpByeC71QhX
+EIQ0ZR56bFuJA/CwValBqV/G9gscTPQqd+iETp8yrFpAVHOW+YzSFbxjTEkBte1J
+aF0vmbqdMAWLk+LEFPQRptZh0B88igtx6tV5oVd+p5IVRM49poLhuPNJGPvMj99l
+mlZ4+AeRUnbOOeAEuvpLJbel4rhwFzmUiGoeTVoPZyMevWcVFq6BMkS+jRR2w0jK
+G6b0v5XDHlcFYPOgUrtsOBFJVwbutLvxdk6q37kIFnWCd8L3kmES5q4wjyFK47Co
+Ja8zlx64jmMZPg/t3wWqkZgXZ14qnbyG5/lGsj5CwVtfDljrhN0oCWK1FZaUmW3d
+69db12/g4f6phldhxiWuGC/W6fCW5kre7nmhshcltqAJJuU47iX+DarBFiIj816e
+yV8e
+-----END CERTIFICATE-----
+"""
+
+ def _prepare_provider_config_with(self, cert_path, cert_hash):
+ """
+ Mocks the provider config to give the cert_path and cert_hash
+ specified
+
+ :param cert_path: path for the certificate
+ :type cert_path: str
+ :param cert_hash: hash for the certificate as it would appear
+ in the provider config json
+ :type cert_hash: str
+ """
+ self.pb._provider_config = mock.Mock()
+ self.pb._provider_config.get_ca_cert_fingerprint = mock.MagicMock(
+ return_value=cert_hash)
+ self.pb._provider_config.get_ca_cert_path = mock.MagicMock(
+ return_value=cert_path)
+ self.pb._domain = "somedomain"
+
+ def test_check_ca_fingerprint_checksout(self):
+ cert_path = os.path.join(tempfile.mkdtemp(),
+ "mynewcert.pem")
+
+ with open(cert_path, "w") as c:
+ c.write(self.KNOWN_GOOD_CERT)
+
+ self._prepare_provider_config_with(cert_path, self.KNOWN_GOOD_HASH)
+
+ self.pb._should_proceed_cert = mock.MagicMock(return_value=True)
+
+ self.pb._check_ca_fingerprint()
+
+ os.unlink(cert_path)
+
+ def test_check_ca_fingerprint_fails(self):
+ cert_path = os.path.join(tempfile.mkdtemp(),
+ "mynewcert.pem")
+
+ with open(cert_path, "w") as c:
+ c.write(self.KNOWN_GOOD_CERT)
+
+ self._prepare_provider_config_with(cert_path, self.KNOWN_BAD_HASH)
+
+ self.pb._should_proceed_cert = mock.MagicMock(return_value=True)
+
+ with self.assertRaises(WrongFingerprint):
+ self.pb._check_ca_fingerprint()
+
+ os.unlink(cert_path)
+
+
+###############################################################################
+# Tests with a fake provider #
+###############################################################################
+
+class ProviderBootstrapperActiveTest(unittest.TestCase):
+ @classmethod
+ def setUpClass(cls):
+ factory = fake_provider.get_provider_factory()
+ http = reactor.listenTCP(8002, factory)
+ https = reactor.listenSSL(
+ 0, factory,
+ fake_provider.OpenSSLServerContextFactory())
+ get_port = lambda p: p.getHost().port
+ cls.http_port = get_port(http)
+ cls.https_port = get_port(https)
+
+ def setUp(self):
+ self.pb = ProviderBootstrapper()
+
+ # At certain points we are going to be replacing these methods
+ # directly in ProviderConfig to be able to catch calls from
+ # new ProviderConfig objects inside the methods tested. We
+ # need to save the old implementation and restore it in
+ # tearDown so we are sure everything is as expected for each
+ # test. If we do it inside each specific test, a failure in
+ # the test will leave the implementation with the mock.
+ self.old_gpp = ProviderConfig.get_path_prefix
+ self.old_load = ProviderConfig.load
+ self.old_save = ProviderConfig.save
+ self.old_api_version = ProviderConfig.get_api_version
+
+ def tearDown(self):
+ ProviderConfig.get_path_prefix = self.old_gpp
+ ProviderConfig.load = self.old_load
+ ProviderConfig.save = self.old_save
+ ProviderConfig.get_api_version = self.old_api_version
+
+ def test_check_https_succeeds(self):
+ # XXX: Need a proper CA signed cert to test this
+ pass
+
+ @deferred()
+ def test_check_https_fails(self):
+ self.pb._domain = "localhost:%s" % (self.https_port,)
+
+ def check(*args):
+ with self.assertRaises(requests.exceptions.SSLError):
+ self.pb._check_https()
+ return threads.deferToThread(check)
+
+ @deferred()
+ def test_second_check_https_fails(self):
+ self.pb._domain = "localhost:1234"
+
+ def check(*args):
+ with self.assertRaises(Exception):
+ self.pb._check_https()
+ return threads.deferToThread(check)
+
+ @deferred()
+ def test_check_https_succeeds_if_danger(self):
+ self.pb._domain = "localhost:%s" % (self.https_port,)
+ self.pb._bypass_checks = True
+
+ def check(*args):
+ self.pb._check_https()
+
+ return threads.deferToThread(check)
+
+ def _setup_provider_config_with(self, api, path_prefix):
+ """
+ Sets up the ProviderConfig with mocks for the path prefix, the
+ api returned and load/save methods.
+ It modifies ProviderConfig directly instead of an object
+ because the object used is created in the method itself and we
+ cannot control that.
+
+ :param api: API to return
+ :type api: str
+ :param path_prefix: path prefix to be used when calculating
+ paths
+ :type path_prefix: str
+ """
+ ProviderConfig.get_path_prefix = mock.MagicMock(
+ return_value=path_prefix)
+ ProviderConfig.get_api_version = mock.MagicMock(
+ return_value=api)
+ ProviderConfig.load = mock.MagicMock()
+ ProviderConfig.save = mock.MagicMock()
+
+ def _setup_providerbootstrapper(self, ifneeded):
+ """
+ Sets the provider bootstrapper's domain to
+ localhost:https_port, sets it to bypass https checks and sets
+ the download if needed based on the ifneeded value.
+
+ :param ifneeded: Value for _download_if_needed
+ :type ifneeded: bool
+ """
+ self.pb._domain = "localhost:%s" % (self.https_port,)
+ self.pb._bypass_checks = True
+ self.pb._download_if_needed = ifneeded
+
+ def _produce_dummy_provider_json(self):
+ """
+ Creates a dummy provider json on disk in order to test
+ behaviour around it (download if newer online, etc)
+
+ :returns: the provider.json path used
+ :rtype: str
+ """
+ provider_dir = os.path.join(ProviderConfig()
+ .get_path_prefix(),
+ "leap",
+ "providers",
+ self.pb._domain)
+ mkdir_p(provider_dir)
+ provider_path = os.path.join(provider_dir,
+ "provider.json")
+
+ with open(provider_path, "w") as p:
+ p.write("A")
+ return provider_path
+
+ def test_download_provider_info_new_provider(self):
+ self._setup_provider_config_with("1", tempfile.mkdtemp())
+ self._setup_providerbootstrapper(True)
+
+ self.pb._download_provider_info()
+ self.assertTrue(ProviderConfig.save.called)
+
+ @mock.patch('leap.config.providerconfig.ProviderConfig.get_ca_cert_path',
+ lambda x: where('cacert.pem'))
+ def test_download_provider_info_not_modified(self):
+ self._setup_provider_config_with("1", tempfile.mkdtemp())
+ self._setup_providerbootstrapper(True)
+ provider_path = self._produce_dummy_provider_json()
+
+ # set mtime to something really new
+ os.utime(provider_path, (-1, time.time()))
+
+ with mock.patch.object(
+ ProviderConfig, 'get_api_uri',
+ return_value="https://localhost:%s" % (self.https_port,)):
+ self.pb._download_provider_info()
+ # we check that it doesn't save the provider
+ # config, because it's new enough
+ self.assertFalse(ProviderConfig.save.called)
+
+ @mock.patch('leap.config.providerconfig.ProviderConfig.get_ca_cert_path',
+ lambda x: where('cacert.pem'))
+ def test_download_provider_info_modified(self):
+ self._setup_provider_config_with("1", tempfile.mkdtemp())
+ self._setup_providerbootstrapper(True)
+ provider_path = self._produce_dummy_provider_json()
+
+ # set mtime to something really old
+ os.utime(provider_path, (-1, 100))
+
+ with mock.patch.object(
+ ProviderConfig, 'get_api_uri',
+ return_value="https://localhost:%s" % (self.https_port,)):
+ self.pb._download_provider_info()
+ self.assertTrue(ProviderConfig.load.called)
+ self.assertTrue(ProviderConfig.save.called)
+
+ @mock.patch('leap.config.providerconfig.ProviderConfig.get_ca_cert_path',
+ lambda x: where('cacert.pem'))
+ def test_download_provider_info_unsupported_api_raises(self):
+ self._setup_provider_config_with("9999999", tempfile.mkdtemp())
+ self._setup_providerbootstrapper(False)
+ self._produce_dummy_provider_json()
+
+ with mock.patch.object(
+ ProviderConfig, 'get_api_uri',
+ return_value="https://localhost:%s" % (self.https_port,)):
+ with self.assertRaises(UnsupportedProviderAPI):
+ self.pb._download_provider_info()
+
+ @mock.patch('leap.config.providerconfig.ProviderConfig.get_ca_cert_path',
+ lambda x: where('cacert.pem'))
+ def test_download_provider_info_unsupported_api(self):
+ self._setup_provider_config_with(SupportedAPIs.SUPPORTED_APIS[0],
+ tempfile.mkdtemp())
+ self._setup_providerbootstrapper(False)
+ self._produce_dummy_provider_json()
+
+ with mock.patch.object(
+ ProviderConfig, 'get_api_uri',
+ return_value="https://localhost:%s" % (self.https_port,)):
+ self.pb._download_provider_info()
+
+ @mock.patch('leap.config.providerconfig.ProviderConfig.get_api_uri',
+ lambda x: 'api.uri')
+ @mock.patch('leap.config.providerconfig.ProviderConfig.get_ca_cert_path',
+ lambda x: '/cert/path')
+ def test_check_api_certificate_skips(self):
+ self.pb._provider_config = ProviderConfig()
+ self.pb._session.get = mock.MagicMock(return_value=Response())
+
+ self.pb._should_proceed_cert = mock.MagicMock(return_value=False)
+ self.pb._check_api_certificate()
+ self.assertFalse(self.pb._session.get.called)
+
+ @deferred()
+ def test_check_api_certificate_fails(self):
+ self.pb._provider_config = ProviderConfig()
+ self.pb._provider_config.get_api_uri = mock.MagicMock(
+ return_value="https://localhost:%s" % (self.https_port,))
+ self.pb._provider_config.get_ca_cert_path = mock.MagicMock(
+ return_value=os.path.join(
+ os.path.split(__file__)[0],
+ "wrongcert.pem"))
+ self.pb._provider_config.get_api_version = mock.MagicMock(
+ return_value="1")
+
+ self.pb._should_proceed_cert = mock.MagicMock(return_value=True)
+
+ def check(*args):
+ with self.assertRaises(requests.exceptions.SSLError):
+ self.pb._check_api_certificate()
+ d = threads.deferToThread(check)
+ return d
+
+ @deferred()
+ def test_check_api_certificate_succeeds(self):
+ self.pb._provider_config = ProviderConfig()
+ self.pb._provider_config.get_api_uri = mock.MagicMock(
+ return_value="https://localhost:%s" % (self.https_port,))
+ self.pb._provider_config.get_ca_cert_path = mock.MagicMock(
+ return_value=where('cacert.pem'))
+ self.pb._provider_config.get_api_version = mock.MagicMock(
+ return_value="1")
+
+ self.pb._should_proceed_cert = mock.MagicMock(return_value=True)
+
+ def check(*args):
+ self.pb._check_api_certificate()
+ d = threads.deferToThread(check)
+ return d
diff --git a/src/leap/bitmask/services/eip/tests/test_vpngatewayselector.py b/src/leap/bitmask/services/eip/tests/test_vpngatewayselector.py
new file mode 100644
index 00000000..f9a177a9
--- /dev/null
+++ b/src/leap/bitmask/services/eip/tests/test_vpngatewayselector.py
@@ -0,0 +1,132 @@
+# -*- coding: utf-8 -*-
+# test_vpngatewayselector.py
+# Copyright (C) 2013 LEAP
+#
+# 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 vpngatewayselector
+"""
+
+import unittest
+
+from leap.bitmask.services.eip.eipconfig import EIPConfig, VPNGatewaySelector
+from leap.common.testing.basetest import BaseLeapTest
+
+from mock import Mock
+
+
+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 VPNGatewaySelectorTest(BaseLeapTest):
+ """
+ VPNGatewaySelector's tests.
+ """
+ def setUp(self):
+ self.eipconfig = EIPConfig()
+ self.eipconfig.get_gateways = Mock(return_value=sample_gateways)
+ self.eipconfig.get_locations = Mock(return_value=sample_locations)
+
+ def tearDown(self):
+ pass
+
+ def test_get_no_gateways(self):
+ gateway_selector = VPNGatewaySelector(self.eipconfig)
+ self.eipconfig.get_gateways = Mock(return_value=[])
+ gateways = gateway_selector.get_gateways()
+ self.assertEqual(gateways, [])
+
+ def test_get_gateway_with_no_locations(self):
+ gateway_selector = VPNGatewaySelector(self.eipconfig)
+ self.eipconfig.get_gateways = Mock(
+ return_value=sample_gateways_no_location)
+ self.eipconfig.get_locations = Mock(return_value=[])
+ gateways = gateway_selector.get_gateways()
+ gateways_default_order = [
+ sample_gateways[0]['ip_address'],
+ sample_gateways[1]['ip_address'],
+ sample_gateways[2]['ip_address']
+ ]
+ self.assertEqual(gateways, gateways_default_order)
+
+ def test_correct_order_gmt(self):
+ gateway_selector = VPNGatewaySelector(self.eipconfig, 0)
+ gateways = gateway_selector.get_gateways()
+ self.assertEqual(gateways, [ips[1], ips[3], ips[2], ips[4]])
+
+ def test_correct_order_gmt_minus_3(self):
+ gateway_selector = VPNGatewaySelector(self.eipconfig, -3)
+ gateways = gateway_selector.get_gateways()
+ self.assertEqual(gateways, [ips[3], ips[2], ips[1], ips[4]])
+
+ def test_correct_order_gmt_minus_7(self):
+ gateway_selector = VPNGatewaySelector(self.eipconfig, -7)
+ gateways = gateway_selector.get_gateways()
+ self.assertEqual(gateways, [ips[2], ips[3], ips[4], ips[1]])
+
+ def test_correct_order_gmt_plus_5(self):
+ gateway_selector = VPNGatewaySelector(self.eipconfig, 5)
+ gateways = gateway_selector.get_gateways()
+ self.assertEqual(gateways, [ips[1], ips[4], ips[3], ips[2]])
+
+ def test_correct_order_gmt_plus_12(self):
+ gateway_selector = VPNGatewaySelector(self.eipconfig, 12)
+ gateways = gateway_selector.get_gateways()
+ self.assertEqual(gateways, [ips[4], ips[2], ips[3], ips[1]])
+
+ def test_correct_order_gmt_minus_11(self):
+ gateway_selector = VPNGatewaySelector(self.eipconfig, -11)
+ gateways = gateway_selector.get_gateways()
+ self.assertEqual(gateways, [ips[4], ips[2], ips[3], ips[1]])
+
+ def test_correct_order_gmt_plus_14(self):
+ gateway_selector = VPNGatewaySelector(self.eipconfig, 14)
+ gateways = gateway_selector.get_gateways()
+ self.assertEqual(gateways, [ips[4], ips[2], ips[3], ips[1]])
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/src/leap/bitmask/services/eip/tests/wrongcert.pem b/src/leap/bitmask/services/eip/tests/wrongcert.pem
new file mode 100644
index 00000000..e6cff38a
--- /dev/null
+++ b/src/leap/bitmask/services/eip/tests/wrongcert.pem
@@ -0,0 +1,33 @@
+-----BEGIN CERTIFICATE-----
+MIIFtTCCA52gAwIBAgIJAIWZus5EIXNtMA0GCSqGSIb3DQEBBQUAMEUxCzAJBgNV
+BAYTAkFVMRMwEQYDVQQIEwpTb21lLVN0YXRlMSEwHwYDVQQKExhJbnRlcm5ldCBX
+aWRnaXRzIFB0eSBMdGQwHhcNMTMwNjI1MTc0NjExWhcNMTgwNjI1MTc0NjExWjBF
+MQswCQYDVQQGEwJBVTETMBEGA1UECBMKU29tZS1TdGF0ZTEhMB8GA1UEChMYSW50
+ZXJuZXQgV2lkZ2l0cyBQdHkgTHRkMIICIjANBgkqhkiG9w0BAQEFAAOCAg8AMIIC
+CgKCAgEA2ObM7ESjyuxFZYD/Y68qOPQgjgggW+cdXfBpU2p4n7clsrUeMhWdW40Y
+77Phzor9VOeqs3ZpHuyLzsYVp/kFDm8tKyo2ah5fJwzL0VCSLYaZkUQQ7GNUmTCk
+furaxl8cQx/fg395V7/EngsS9B3/y5iHbctbA4MnH3jaotO5EGeo6hw7/eyCotQ9
+KbBV9GJMcY94FsXBCmUB+XypKklWTLhSaS6Cu4Fo8YLW6WmcnsyEOGS2F7WVf5at
+7CBWFQZHaSgIBLmc818/mDYCnYmCVMFn/6Ndx7V2NTlz+HctWrQn0dmIOnCUeCwS
+wXq9PnBR1rSx/WxwyF/WpyjOFkcIo7vm72kS70pfrYsXcZD4BQqkXYj3FyKnPt3O
+ibLKtCxL8/83wOtErPcYpG6LgFkgAAlHQ9MkUi5dbmjCJtpqQmlZeK1RALdDPiB3
+K1KZimrGsmcE624dJxUIOJJpuwJDy21F8kh5ZAsAtE1prWETrQYNElNFjQxM83rS
+ZR1Ql2MPSB4usEZT57+KvpEzlOnAT3elgCg21XrjSFGi14hCEao4g2OEZH5GAwm5
+frf6UlSRZ/g3tLTfI8Hv1prw15W2qO+7q7SBAplTODCRk+Yb0YoA2mMM/QXBUcXs
+vKEDLSSxzNIBi3T62l39RB/ml+gPKo87ZMDivex1ZhrcJc3Yu3sCAwEAAaOBpzCB
+pDAdBgNVHQ4EFgQUPjE+4pun+8FreIdpoR8v6N7xKtUwdQYDVR0jBG4wbIAUPjE+
+4pun+8FreIdpoR8v6N7xKtWhSaRHMEUxCzAJBgNVBAYTAkFVMRMwEQYDVQQIEwpT
+b21lLVN0YXRlMSEwHwYDVQQKExhJbnRlcm5ldCBXaWRnaXRzIFB0eSBMdGSCCQCF
+mbrORCFzbTAMBgNVHRMEBTADAQH/MA0GCSqGSIb3DQEBBQUAA4ICAQCpvCPdtvXJ
+muTj379TZuCJs7/l0FhA7AHa1WAlHjsXHaA7N0+3ZWAbdtXDsowal6S+ldgU/kfV
+Lq7NrRq+amJWC7SYj6cvVwhrSwSvu01fe/TWuOzHrRv1uTfJ/VXLonVufMDd9opo
+bhqYxMaxLdIx6t/MYmZH4Wpiq0yfZuv//M8i7BBl/qvaWbLhg0yVAKRwjFvf59h6
+6tRFCLddELOIhLDQtk8zMbioPEbfAlKdwwP8kYGtDGj6/9/YTd/oTKRdgHuwyup3
+m0L20Y6LddC+tb0WpK5EyrNbCbEqj1L4/U7r6f/FKNA3bx6nfdXbscaMfYonKAKg
+1cRrRg45sErmCz0QyTnWzXyvbjR4oQRzyW3kJ1JZudZ+AwOi00J5FYa3NiLuxl1u
+gIGKWSrASQWhEdpa1nlCgX7PhdaQgYjEMpQvA0GCA0OF5JDu8en1yZqsOt1hCLIN
+lkz/5jKPqrclY5hV99bE3hgCHRmIPNHCZG3wbZv2yJKxJX1YLMmQwAmSh2N7YwGG
+yXRvCxQs5ChPHyRairuf/5MZCZnSVb45ppTVuNUijsbflKRUgfj/XvfqQ22f+C9N
+Om2dmNvAiS2TOIfuP47CF2OUa5q4plUwmr+nyXQGM0SIoHNCj+MBdFfb3oxxAtI+
+SLhbnzQv5e84Doqz3YF0XW8jyR7q8GFLNA==
+-----END CERTIFICATE-----
diff --git a/src/leap/bitmask/services/eip/udstelnet.py b/src/leap/bitmask/services/eip/udstelnet.py
new file mode 100644
index 00000000..e6c82350
--- /dev/null
+++ b/src/leap/bitmask/services/eip/udstelnet.py
@@ -0,0 +1,60 @@
+# -*- coding: utf-8 -*-
+# udstelnet.py
+# Copyright (C) 2013 LEAP
+#
+# 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/>.
+
+import os
+import socket
+import telnetlib
+
+
+class ConnectionRefusedError(Exception):
+ pass
+
+
+class MissingSocketError(Exception):
+ pass
+
+
+class UDSTelnet(telnetlib.Telnet):
+ """
+ A telnet-alike class, that can listen on unix domain sockets
+ """
+
+ def open(self, host, port=23, timeout=socket._GLOBAL_DEFAULT_TIMEOUT):
+ """
+ Connect to a host. If port is 'unix', it will open a
+ connection over unix docmain sockets.
+
+ The optional second argument is the port number, which
+ defaults to the standard telnet port (23).
+ Don't try to reopen an already connected instance.
+ """
+ self.eof = 0
+ self.host = host
+ self.port = port
+ self.timeout = timeout
+
+ if self.port == "unix":
+ # unix sockets spoken
+ if not os.path.exists(self.host):
+ raise MissingSocketError()
+ self.sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
+ try:
+ self.sock.connect(self.host)
+ except socket.error:
+ raise ConnectionRefusedError()
+ else:
+ self.sock = socket.create_connection((host, port), timeout)
diff --git a/src/leap/bitmask/services/eip/vpnlaunchers.py b/src/leap/bitmask/services/eip/vpnlaunchers.py
new file mode 100644
index 00000000..8a127ce9
--- /dev/null
+++ b/src/leap/bitmask/services/eip/vpnlaunchers.py
@@ -0,0 +1,927 @@
+# -*- coding: utf-8 -*-
+# vpnlaunchers.py
+# Copyright (C) 2013 LEAP
+#
+# 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/>.
+
+"""
+Platform dependant VPN launchers
+"""
+import commands
+import logging
+import getpass
+import os
+import platform
+import subprocess
+import stat
+try:
+ import grp
+except ImportError:
+ pass # ignore, probably windows
+
+from abc import ABCMeta, abstractmethod
+from functools import partial
+
+from leap.bitmask.config.providerconfig import ProviderConfig
+from leap.bitmask.services.eip.eipconfig import EIPConfig, VPNGatewaySelector
+from leap.bitmask.util import first
+from leap.bitmask.util.privilege_policies import LinuxPolicyChecker
+from leap.bitmask.util import privilege_policies
+from leap.common.check import leap_assert, leap_assert_type
+from leap.common.files import which
+
+logger = logging.getLogger(__name__)
+
+
+class VPNLauncherException(Exception):
+ pass
+
+
+class OpenVPNNotFoundException(VPNLauncherException):
+ pass
+
+
+class EIPNoPolkitAuthAgentAvailable(VPNLauncherException):
+ pass
+
+
+class EIPNoPkexecAvailable(VPNLauncherException):
+ pass
+
+
+class EIPNoTunKextLoaded(VPNLauncherException):
+ pass
+
+
+class VPNLauncher(object):
+ """
+ Abstract launcher class
+ """
+ __metaclass__ = ABCMeta
+
+ UPDOWN_FILES = None
+ OTHER_FILES = None
+
+ @abstractmethod
+ def get_vpn_command(self, eipconfig=None, providerconfig=None,
+ socket_host=None, socket_port=None):
+ """
+ Returns the platform dependant vpn launching command
+
+ :param eipconfig: eip configuration object
+ :type eipconfig: EIPConfig
+ :param providerconfig: provider specific configuration
+ :type providerconfig: ProviderConfig
+ :param socket_host: either socket path (unix) or socket IP
+ :type socket_host: str
+ :param socket_port: either string "unix" if it's a unix
+ socket, or port otherwise
+ :type socket_port: str
+
+ :return: A VPN command ready to be launched
+ :rtype: list
+ """
+ return []
+
+ @abstractmethod
+ def get_vpn_env(self, providerconfig):
+ """
+ Returns a dictionary with the custom env for the platform.
+ This is mainly used for setting LD_LIBRARY_PATH to the correct
+ path when distributing a standalone client
+
+ :param providerconfig: provider specific configuration
+ :type providerconfig: ProviderConfig
+
+ :rtype: dict
+ """
+ return {}
+
+ @classmethod
+ def missing_updown_scripts(kls):
+ """
+ Returns what updown scripts are missing.
+ :rtype: list
+ """
+ leap_assert(kls.UPDOWN_FILES is not None,
+ "Need to define UPDOWN_FILES for this particular "
+ "auncher before calling this method")
+ file_exist = partial(_has_updown_scripts, warn=False)
+ zipped = zip(kls.UPDOWN_FILES, map(file_exist, kls.UPDOWN_FILES))
+ missing = filter(lambda (path, exists): exists is False, zipped)
+ return [path for path, exists in missing]
+
+ @classmethod
+ def missing_other_files(kls):
+ """
+ Returns what other important files are missing during startup.
+ Same as missing_updown_scripts but does not check for exec bit.
+ :rtype: list
+ """
+ leap_assert(kls.UPDOWN_FILES is not None,
+ "Need to define OTHER_FILES for this particular "
+ "auncher before calling this method")
+ file_exist = partial(_has_other_files, warn=False)
+ zipped = zip(kls.OTHER_FILES, map(file_exist, kls.OTHER_FILES))
+ missing = filter(lambda (path, exists): exists is False, zipped)
+ return [path for path, exists in missing]
+
+
+def get_platform_launcher():
+ launcher = globals()[platform.system() + "VPNLauncher"]
+ leap_assert(launcher, "Unimplemented platform launcher: %s" %
+ (platform.system(),))
+ return launcher()
+
+
+def _is_pkexec_in_system():
+ """
+ Checks the existence of the pkexec binary in system.
+ """
+ pkexec_path = which('pkexec')
+ if len(pkexec_path) == 0:
+ return False
+ return True
+
+
+def _has_updown_scripts(path, warn=True):
+ """
+ Checks the existence of the up/down scripts and its
+ exec bit if applicable.
+
+ :param path: the path to be checked
+ :type path: str
+
+ :param warn: whether we should log the absence
+ :type warn: bool
+
+ :rtype: bool
+ """
+ is_file = os.path.isfile(path)
+ if warn and not is_file:
+ logger.error("Could not find up/down script %s. "
+ "Might produce DNS leaks." % (path,))
+
+ # XXX check if applies in win
+ is_exe = False
+ try:
+ is_exe = (stat.S_IXUSR & os.stat(path)[stat.ST_MODE] != 0)
+ except OSError as e:
+ logger.warn("%s" % (e,))
+ if warn and not is_exe:
+ logger.error("Up/down script %s is not executable. "
+ "Might produce DNS leaks." % (path,))
+ return is_file and is_exe
+
+
+def _has_other_files(path, warn=True):
+ """
+ Checks the existence of other important files.
+
+ :param path: the path to be checked
+ :type path: str
+
+ :param warn: whether we should log the absence
+ :type warn: bool
+
+ :rtype: bool
+ """
+ is_file = os.path.isfile(path)
+ if warn and not is_file:
+ logger.warning("Could not find file during checks: %s. " % (
+ path,))
+ return is_file
+
+
+def _is_auth_agent_running():
+ """
+ Checks if a polkit daemon is running.
+
+ :return: True if it's running, False if it's not.
+ :rtype: boolean
+ """
+ ps = 'ps aux | grep polkit-%s-authentication-agent-1'
+ opts = (ps % case for case in ['[g]nome', '[k]de'])
+ is_running = map(lambda l: commands.getoutput(l), opts)
+ return any(is_running)
+
+
+def _try_to_launch_agent():
+ """
+ Tries to launch a polkit daemon.
+ """
+ opts = [
+ "/usr/lib/policykit-1-gnome/polkit-gnome-authentication-agent-1",
+ # XXX add kde thing here
+ ]
+ for cmd in opts:
+ try:
+ subprocess.Popen([cmd], shell=True)
+ except:
+ pass
+
+
+class LinuxVPNLauncher(VPNLauncher):
+ """
+ VPN launcher for the Linux platform
+ """
+
+ PKEXEC_BIN = 'pkexec'
+ OPENVPN_BIN = 'openvpn'
+ OPENVPN_BIN_PATH = os.path.join(
+ ProviderConfig().get_path_prefix(),
+ "..", "apps", "eip", OPENVPN_BIN)
+
+ SYSTEM_CONFIG = "/etc/leap"
+ UP_DOWN_FILE = "resolv-update"
+ UP_DOWN_PATH = "%s/%s" % (SYSTEM_CONFIG, UP_DOWN_FILE)
+
+ # We assume this is there by our openvpn dependency, and
+ # we will put it there on the bundle too.
+ # TODO adapt to the bundle path.
+ OPENVPN_DOWN_ROOT_BASE = "/usr/lib/openvpn/"
+ OPENVPN_DOWN_ROOT_FILE = "openvpn-plugin-down-root.so"
+ OPENVPN_DOWN_ROOT_PATH = "%s/%s" % (
+ OPENVPN_DOWN_ROOT_BASE,
+ OPENVPN_DOWN_ROOT_FILE)
+
+ UPDOWN_FILES = (UP_DOWN_PATH,)
+ POLKIT_PATH = LinuxPolicyChecker.get_polkit_path()
+ OTHER_FILES = (POLKIT_PATH, )
+
+ def missing_other_files(self):
+ """
+ 'Extend' the VPNLauncher's missing_other_files to check if the polkit
+ files is outdated. If the polkit file that is in OTHER_FILES exists but
+ is not up to date, it is added to the missing list.
+
+ :returns: a list of missing files
+ :rtype: list of str
+ """
+ missing = VPNLauncher.missing_other_files.im_func(self)
+ polkit_file = LinuxPolicyChecker.get_polkit_path()
+ if polkit_file not in missing:
+ if privilege_policies.is_policy_outdated(self.OPENVPN_BIN_PATH):
+ missing.append(polkit_file)
+
+ return missing
+
+ @classmethod
+ def cmd_for_missing_scripts(kls, frompath, pol_file):
+ """
+ Returns a sh script that can copy the missing files.
+
+ :param frompath: The path where the up/down scripts live
+ :type frompath: str
+ :param pol_file: The path where the dynamically generated
+ policy file lives
+ :type pol_file: str
+
+ :rtype: str
+ """
+ to = kls.SYSTEM_CONFIG
+
+ cmd = '#!/bin/sh\n'
+ cmd += 'mkdir -p "%s"\n' % (to, )
+ cmd += 'cp "%s/%s" "%s"\n' % (frompath, kls.UP_DOWN_FILE, to)
+ cmd += 'cp "%s" "%s"\n' % (pol_file, kls.POLKIT_PATH)
+ cmd += 'chmod 644 "%s"\n' % (kls.POLKIT_PATH, )
+
+ return cmd
+
+ @classmethod
+ def maybe_pkexec(kls):
+ """
+ Checks whether pkexec is available in the system, and
+ returns the path if found.
+
+ Might raise EIPNoPkexecAvailable or EIPNoPolkitAuthAgentAvailable
+
+ :returns: a list of the paths where pkexec is to be found
+ :rtype: list
+ """
+ if _is_pkexec_in_system():
+ if not _is_auth_agent_running():
+ _try_to_launch_agent()
+ if _is_auth_agent_running():
+ pkexec_possibilities = which(kls.PKEXEC_BIN)
+ leap_assert(len(pkexec_possibilities) > 0,
+ "We couldn't find pkexec")
+ return pkexec_possibilities
+ else:
+ logger.warning("No polkit auth agent found. pkexec " +
+ "will use its own auth agent.")
+ raise EIPNoPolkitAuthAgentAvailable()
+ else:
+ logger.warning("System has no pkexec")
+ raise EIPNoPkexecAvailable()
+
+ @classmethod
+ def maybe_down_plugin(kls):
+ """
+ Returns the path of the openvpn down-root-plugin, searching first
+ in the relative path for the standalone bundle, and then in the system
+ path where the debian package puts it.
+
+ :returns: the path where the plugin was found, or None
+ :rtype: str or None
+ """
+ cwd = os.getcwd()
+ rel_path_in_bundle = os.path.join(
+ 'apps', 'eip', 'files', kls.OPENVPN_DOWN_ROOT_FILE)
+ abs_path_in_bundle = os.path.join(cwd, rel_path_in_bundle)
+ if os.path.isfile(abs_path_in_bundle):
+ return abs_path_in_bundle
+ abs_path_in_system = kls.OPENVPN_DOWN_ROOT_FILE
+ if os.path.isfile(abs_path_in_system):
+ return abs_path_in_system
+
+ logger.warning("We could not find the down-root-plugin, so no updown "
+ "scripts will be run. DNS leaks are likely!")
+ return None
+
+ def get_vpn_command(self, eipconfig=None, providerconfig=None,
+ socket_host=None, socket_port="unix", openvpn_verb=1):
+ """
+ Returns the platform dependant vpn launching command. It will
+ look for openvpn in the regular paths and algo in
+ path_prefix/apps/eip/ (in case standalone is set)
+
+ Might raise:
+ VPNLauncherException,
+ OpenVPNNotFoundException.
+
+ :param eipconfig: eip configuration object
+ :type eipconfig: EIPConfig
+
+ :param providerconfig: provider specific configuration
+ :type providerconfig: ProviderConfig
+
+ :param socket_host: either socket path (unix) or socket IP
+ :type socket_host: str
+
+ :param socket_port: either string "unix" if it's a unix
+ socket, or port otherwise
+ :type socket_port: str
+
+ :param openvpn_verb: openvpn verbosity wanted
+ :type openvpn_verb: int
+
+ :return: A VPN command ready to be launched
+ :rtype: list
+ """
+ leap_assert(eipconfig, "We need an eip config")
+ leap_assert_type(eipconfig, EIPConfig)
+ leap_assert(providerconfig, "We need a provider config")
+ leap_assert_type(providerconfig, ProviderConfig)
+ leap_assert(socket_host, "We need a socket host!")
+ leap_assert(socket_port, "We need a socket port!")
+
+ kwargs = {}
+ if ProviderConfig.standalone:
+ kwargs['path_extension'] = os.path.join(
+ providerconfig.get_path_prefix(),
+ "..", "apps", "eip")
+
+ openvpn_possibilities = which(self.OPENVPN_BIN, **kwargs)
+
+ if len(openvpn_possibilities) == 0:
+ raise OpenVPNNotFoundException()
+
+ openvpn = first(openvpn_possibilities)
+ args = []
+
+ pkexec = self.maybe_pkexec()
+ if pkexec:
+ args.append(openvpn)
+ openvpn = first(pkexec)
+
+ if openvpn_verb is not None:
+ args += ['--verb', '%d' % (openvpn_verb,)]
+
+ gateway_selector = VPNGatewaySelector(eipconfig)
+ gateways = gateway_selector.get_gateways()
+
+ if not gateways:
+ logger.error('No gateway was found!')
+ raise VPNLauncherException(self.tr('No gateway was found!'))
+
+ logger.debug("Using gateways ips: {}".format(', '.join(gateways)))
+
+ for gw in gateways:
+ args += ['--remote', gw, '1194', 'udp']
+
+ args += [
+ '--client',
+ '--dev', 'tun',
+ ##############################################################
+ # persist-tun makes ping-restart fail because it leaves a
+ # broken routing table
+ ##############################################################
+ # '--persist-tun',
+ '--persist-key',
+ '--tls-client',
+ '--remote-cert-tls',
+ 'server'
+ ]
+
+ openvpn_configuration = eipconfig.get_openvpn_configuration()
+
+ for key, value in openvpn_configuration.items():
+ args += ['--%s' % (key,), value]
+
+ ##############################################################
+ # The down-root plugin fails in some situations, so we don't
+ # drop privs for the time being
+ ##############################################################
+ # args += [
+ # '--user', getpass.getuser(),
+ # '--group', grp.getgrgid(os.getgroups()[-1]).gr_name
+ # ]
+
+ if socket_port == "unix": # that's always the case for linux
+ args += [
+ '--management-client-user', getpass.getuser()
+ ]
+
+ args += [
+ '--management-signal',
+ '--management', socket_host, socket_port,
+ '--script-security', '2'
+ ]
+
+ plugin_path = self.maybe_down_plugin()
+ # If we do not have the down plugin neither in the bundle
+ # nor in the system, we do not do updown scripts. The alternative
+ # is leaving the user without the ability to restore dns and routes
+ # to its original state.
+
+ if plugin_path and _has_updown_scripts(self.UP_DOWN_PATH):
+ args += [
+ '--up', self.UP_DOWN_PATH,
+ '--down', self.UP_DOWN_PATH,
+ ##############################################################
+ # For the time being we are disabling the usage of the
+ # down-root plugin, because it doesn't quite work as
+ # expected (i.e. it doesn't run route -del as root
+ # when finishing, so it fails to properly
+ # restart/quit)
+ ##############################################################
+ # '--plugin', plugin_path,
+ # '\'script_type=down %s\'' % self.UP_DOWN_PATH
+ ]
+
+ args += [
+ '--cert', eipconfig.get_client_cert_path(providerconfig),
+ '--key', eipconfig.get_client_cert_path(providerconfig),
+ '--ca', providerconfig.get_ca_cert_path()
+ ]
+
+ logger.debug("Running VPN with command:")
+ logger.debug("%s %s" % (openvpn, " ".join(args)))
+
+ return [openvpn] + args
+
+ def get_vpn_env(self, providerconfig):
+ """
+ Returns a dictionary with the custom env for the platform.
+ This is mainly used for setting LD_LIBRARY_PATH to the correct
+ path when distributing a standalone client
+
+ :param providerconfig: provider specific configuration
+ :type providerconfig: ProviderConfig
+
+ :rtype: dict
+ """
+ leap_assert(providerconfig, "We need a provider config")
+ leap_assert_type(providerconfig, ProviderConfig)
+
+ return {"LD_LIBRARY_PATH": os.path.join(
+ providerconfig.get_path_prefix(),
+ "..", "lib")}
+
+
+class DarwinVPNLauncher(VPNLauncher):
+ """
+ VPN launcher for the Darwin Platform
+ """
+
+ COCOASUDO = "cocoasudo"
+ # XXX need the good old magic translate for these strings
+ # (look for magic in 0.2.0 release)
+ SUDO_MSG = ("Bitmask needs administrative privileges to run "
+ "Encrypted Internet.")
+ INSTALL_MSG = ("\"Bitmask needs administrative privileges to install "
+ "missing scripts and fix permissions.\"")
+
+ INSTALL_PATH = os.path.realpath(os.getcwd() + "/../../")
+ INSTALL_PATH_ESCAPED = os.path.realpath(os.getcwd() + "/../../")
+ OPENVPN_BIN = 'openvpn.leap'
+ OPENVPN_PATH = "%s/Contents/Resources/openvpn" % (INSTALL_PATH,)
+ OPENVPN_PATH_ESCAPED = "%s/Contents/Resources/openvpn" % (
+ INSTALL_PATH_ESCAPED,)
+
+ UP_SCRIPT = "%s/client.up.sh" % (OPENVPN_PATH,)
+ DOWN_SCRIPT = "%s/client.down.sh" % (OPENVPN_PATH,)
+ OPENVPN_DOWN_PLUGIN = '%s/openvpn-down-root.so' % (OPENVPN_PATH,)
+
+ UPDOWN_FILES = (UP_SCRIPT, DOWN_SCRIPT, OPENVPN_DOWN_PLUGIN)
+ OTHER_FILES = []
+
+ @classmethod
+ def cmd_for_missing_scripts(kls, frompath):
+ """
+ Returns a command that can copy the missing scripts.
+ :rtype: str
+ """
+ to = kls.OPENVPN_PATH_ESCAPED
+ cmd = "#!/bin/sh\nmkdir -p %s\ncp \"%s/\"* %s\nchmod 744 %s/*" % (
+ to, frompath, to, to)
+ return cmd
+
+ @classmethod
+ def maybe_kextloaded(kls):
+ """
+ Checks if the needed kext is loaded before launching openvpn.
+ """
+ return bool(commands.getoutput('kextstat | grep "leap.tun"'))
+
+ def _get_resource_path(self):
+ """
+ Returns the absolute path to the app resources directory
+
+ :rtype: str
+ """
+ return os.path.abspath(
+ os.path.join(
+ os.getcwd(),
+ "../../Contents/Resources"))
+
+ def _get_icon_path(self):
+ """
+ Returns the absolute path to the app icon
+
+ :rtype: str
+ """
+ return os.path.join(self._get_resource_path(),
+ "leap-client.tiff")
+
+ def get_cocoasudo_ovpn_cmd(self):
+ """
+ Returns a string with the cocoasudo command needed to run openvpn
+ as admin with a nice password prompt. The actual command needs to be
+ appended.
+
+ :rtype: (str, list)
+ """
+ iconpath = self._get_icon_path()
+ has_icon = os.path.isfile(iconpath)
+ args = ["--icon=%s" % iconpath] if has_icon else []
+ args.append("--prompt=%s" % (self.SUDO_MSG,))
+
+ return self.COCOASUDO, args
+
+ def get_cocoasudo_installmissing_cmd(self):
+ """
+ Returns a string with the cocoasudo command needed to install missing
+ files as admin with a nice password prompt. The actual command needs to
+ be appended.
+
+ :rtype: (str, list)
+ """
+ iconpath = self._get_icon_path()
+ has_icon = os.path.isfile(iconpath)
+ args = ["--icon=%s" % iconpath] if has_icon else []
+ args.append("--prompt=%s" % (self.INSTALL_MSG,))
+
+ return self.COCOASUDO, args
+
+ def get_vpn_command(self, eipconfig=None, providerconfig=None,
+ socket_host=None, socket_port="unix", openvpn_verb=1):
+ """
+ Returns the platform dependant vpn launching command
+
+ Might raise VPNException.
+
+ :param eipconfig: eip configuration object
+ :type eipconfig: EIPConfig
+
+ :param providerconfig: provider specific configuration
+ :type providerconfig: ProviderConfig
+
+ :param socket_host: either socket path (unix) or socket IP
+ :type socket_host: str
+
+ :param socket_port: either string "unix" if it's a unix
+ socket, or port otherwise
+ :type socket_port: str
+
+ :param openvpn_verb: openvpn verbosity wanted
+ :type openvpn_verb: int
+
+ :return: A VPN command ready to be launched
+ :rtype: list
+ """
+ leap_assert(eipconfig, "We need an eip config")
+ leap_assert_type(eipconfig, EIPConfig)
+ leap_assert(providerconfig, "We need a provider config")
+ leap_assert_type(providerconfig, ProviderConfig)
+ leap_assert(socket_host, "We need a socket host!")
+ leap_assert(socket_port, "We need a socket port!")
+
+ if not self.maybe_kextloaded():
+ raise EIPNoTunKextLoaded
+
+ kwargs = {}
+ if ProviderConfig.standalone:
+ kwargs['path_extension'] = os.path.join(
+ providerconfig.get_path_prefix(),
+ "..", "apps", "eip")
+
+ openvpn_possibilities = which(
+ self.OPENVPN_BIN,
+ **kwargs)
+ if len(openvpn_possibilities) == 0:
+ raise OpenVPNNotFoundException()
+
+ openvpn = first(openvpn_possibilities)
+ args = [openvpn]
+
+ if openvpn_verb is not None:
+ args += ['--verb', '%d' % (openvpn_verb,)]
+
+ gateway_selector = VPNGatewaySelector(eipconfig)
+ gateways = gateway_selector.get_gateways()
+
+ logger.debug("Using gateways ips: {gw}".format(
+ gw=', '.join(gateways)))
+
+ for gw in gateways:
+ args += ['--remote', gw, '1194', 'udp']
+
+ args += [
+ '--client',
+ '--dev', 'tun',
+ ##############################################################
+ # persist-tun makes ping-restart fail because it leaves a
+ # broken routing table
+ ##############################################################
+ # '--persist-tun',
+ '--persist-key',
+ '--tls-client',
+ '--remote-cert-tls',
+ 'server'
+ ]
+
+ openvpn_configuration = eipconfig.get_openvpn_configuration()
+ for key, value in openvpn_configuration.items():
+ args += ['--%s' % (key,), value]
+
+ user = getpass.getuser()
+
+ ##############################################################
+ # The down-root plugin fails in some situations, so we don't
+ # drop privs for the time being
+ ##############################################################
+ # args += [
+ # '--user', user,
+ # '--group', grp.getgrgid(os.getgroups()[-1]).gr_name
+ # ]
+
+ if socket_port == "unix":
+ args += [
+ '--management-client-user', user
+ ]
+
+ args += [
+ '--management-signal',
+ '--management', socket_host, socket_port,
+ '--script-security', '2'
+ ]
+
+ if _has_updown_scripts(self.UP_SCRIPT):
+ args += [
+ '--up', '\"%s\"' % (self.UP_SCRIPT,),
+ ]
+
+ if _has_updown_scripts(self.DOWN_SCRIPT):
+ args += [
+ '--down', '\"%s\"' % (self.DOWN_SCRIPT,)
+ ]
+
+ # should have the down script too
+ if _has_updown_scripts(self.OPENVPN_DOWN_PLUGIN):
+ args += [
+ ###########################################################
+ # For the time being we are disabling the usage of the
+ # down-root plugin, because it doesn't quite work as
+ # expected (i.e. it doesn't run route -del as root
+ # when finishing, so it fails to properly
+ # restart/quit)
+ ###########################################################
+ # '--plugin', self.OPENVPN_DOWN_PLUGIN,
+ # '\'%s\'' % self.DOWN_SCRIPT
+ ]
+
+ # we set user to be passed to the up/down scripts
+ args += [
+ '--setenv', "LEAPUSER", "%s" % (user,)]
+
+ args += [
+ '--cert', eipconfig.get_client_cert_path(providerconfig),
+ '--key', eipconfig.get_client_cert_path(providerconfig),
+ '--ca', providerconfig.get_ca_cert_path()
+ ]
+
+ command, cargs = self.get_cocoasudo_ovpn_cmd()
+ cmd_args = cargs + args
+
+ logger.debug("Running VPN with command:")
+ logger.debug("%s %s" % (command, " ".join(cmd_args)))
+
+ return [command] + cmd_args
+
+ def get_vpn_env(self, providerconfig):
+ """
+ Returns a dictionary with the custom env for the platform.
+ This is mainly used for setting LD_LIBRARY_PATH to the correct
+ path when distributing a standalone client
+
+ :param providerconfig: provider specific configuration
+ :type providerconfig: ProviderConfig
+
+ :rtype: dict
+ """
+ return {"DYLD_LIBRARY_PATH": os.path.join(
+ providerconfig.get_path_prefix(),
+ "..", "lib")}
+
+
+class WindowsVPNLauncher(VPNLauncher):
+ """
+ VPN launcher for the Windows platform
+ """
+
+ OPENVPN_BIN = 'openvpn_leap.exe'
+
+ # XXX UPDOWN_FILES ... we do not have updown files defined yet!
+ # (and maybe we won't)
+
+ def get_vpn_command(self, eipconfig=None, providerconfig=None,
+ socket_host=None, socket_port="9876", openvpn_verb=1):
+ """
+ Returns the platform dependant vpn launching command. It will
+ look for openvpn in the regular paths and algo in
+ path_prefix/apps/eip/ (in case standalone is set)
+
+ Might raise VPNException.
+
+ :param eipconfig: eip configuration object
+ :type eipconfig: EIPConfig
+
+ :param providerconfig: provider specific configuration
+ :type providerconfig: ProviderConfig
+
+ :param socket_host: either socket path (unix) or socket IP
+ :type socket_host: str
+
+ :param socket_port: either string "unix" if it's a unix
+ socket, or port otherwise
+ :type socket_port: str
+
+ :param openvpn_verb: the openvpn verbosity wanted
+ :type openvpn_verb: int
+
+ :return: A VPN command ready to be launched
+ :rtype: list
+ """
+ leap_assert(eipconfig, "We need an eip config")
+ leap_assert_type(eipconfig, EIPConfig)
+ leap_assert(providerconfig, "We need a provider config")
+ leap_assert_type(providerconfig, ProviderConfig)
+ leap_assert(socket_host, "We need a socket host!")
+ leap_assert(socket_port, "We need a socket port!")
+ leap_assert(socket_port != "unix",
+ "We cannot use unix sockets in windows!")
+
+ openvpn_possibilities = which(
+ self.OPENVPN_BIN,
+ path_extension=os.path.join(providerconfig.get_path_prefix(),
+ "..", "apps", "eip"))
+
+ if len(openvpn_possibilities) == 0:
+ raise OpenVPNNotFoundException()
+
+ openvpn = first(openvpn_possibilities)
+ args = []
+ if openvpn_verb is not None:
+ args += ['--verb', '%d' % (openvpn_verb,)]
+
+ gateway_selector = VPNGatewaySelector(eipconfig)
+ gateways = gateway_selector.get_gateways()
+
+ logger.debug("Using gateways ips: {}".format(', '.join(gateways)))
+
+ for gw in gateways:
+ args += ['--remote', gw, '1194', 'udp']
+
+ args += [
+ '--client',
+ '--dev', 'tun',
+ ##############################################################
+ # persist-tun makes ping-restart fail because it leaves a
+ # broken routing table
+ ##############################################################
+ # '--persist-tun',
+ '--persist-key',
+ '--tls-client',
+ # We make it log to a file because we cannot attach to the
+ # openvpn process' stdout since it's a process with more
+ # privileges than we are
+ '--log-append', 'eip.log',
+ '--remote-cert-tls',
+ 'server'
+ ]
+
+ openvpn_configuration = eipconfig.get_openvpn_configuration()
+ for key, value in openvpn_configuration.items():
+ args += ['--%s' % (key,), value]
+
+ ##############################################################
+ # The down-root plugin fails in some situations, so we don't
+ # drop privs for the time being
+ ##############################################################
+ # args += [
+ # '--user', getpass.getuser(),
+ # #'--group', grp.getgrgid(os.getgroups()[-1]).gr_name
+ # ]
+
+ args += [
+ '--management-signal',
+ '--management', socket_host, socket_port,
+ '--script-security', '2'
+ ]
+
+ args += [
+ '--cert', eipconfig.get_client_cert_path(providerconfig),
+ '--key', eipconfig.get_client_cert_path(providerconfig),
+ '--ca', providerconfig.get_ca_cert_path()
+ ]
+
+ logger.debug("Running VPN with command:")
+ logger.debug("%s %s" % (openvpn, " ".join(args)))
+
+ return [openvpn] + args
+
+ def get_vpn_env(self, providerconfig):
+ """
+ Returns a dictionary with the custom env for the platform.
+ This is mainly used for setting LD_LIBRARY_PATH to the correct
+ path when distributing a standalone client
+
+ :param providerconfig: provider specific configuration
+ :type providerconfig: ProviderConfig
+
+ :rtype: dict
+ """
+ return {}
+
+
+if __name__ == "__main__":
+ logger = logging.getLogger(name='leap')
+ logger.setLevel(logging.DEBUG)
+ console = logging.StreamHandler()
+ console.setLevel(logging.DEBUG)
+ formatter = logging.Formatter(
+ '%(asctime)s '
+ '- %(name)s - %(levelname)s - %(message)s')
+ console.setFormatter(formatter)
+ logger.addHandler(console)
+
+ try:
+ abs_launcher = VPNLauncher()
+ except Exception as e:
+ assert isinstance(e, TypeError), "Something went wrong"
+ print "Abstract Prefixer class is working as expected"
+
+ vpnlauncher = get_platform_launcher()
+
+ eipconfig = EIPConfig()
+ eipconfig.set_api_version('1')
+ if eipconfig.load("leap/providers/bitmask.net/eip-service.json"):
+ provider = ProviderConfig()
+ if provider.load("leap/providers/bitmask.net/provider.json"):
+ vpnlauncher.get_vpn_command(eipconfig=eipconfig,
+ providerconfig=provider,
+ socket_host="/blah")
diff --git a/src/leap/bitmask/services/eip/vpnprocess.py b/src/leap/bitmask/services/eip/vpnprocess.py
new file mode 100644
index 00000000..497df188
--- /dev/null
+++ b/src/leap/bitmask/services/eip/vpnprocess.py
@@ -0,0 +1,791 @@
+# -*- coding: utf-8 -*-
+# vpnprocess.py
+# Copyright (C) 2013 LEAP
+#
+# 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/>.
+"""
+VPN Manager, spawned in a custom processProtocol.
+"""
+import logging
+import os
+import psutil
+import psutil.error
+import shutil
+import socket
+
+from PySide import QtCore
+
+from leap.bitmask.config.providerconfig import ProviderConfig
+from leap.bitmask.services.eip.vpnlaunchers import get_platform_launcher
+from leap.bitmask.services.eip.eipconfig import EIPConfig
+from leap.bitmask.services.eip.udstelnet import UDSTelnet
+from leap.bitmask.util import first
+from leap.common.check import leap_assert, leap_assert_type
+
+logger = logging.getLogger(__name__)
+vpnlog = logging.getLogger('leap.openvpn')
+
+from twisted.internet import protocol
+from twisted.internet import defer
+from twisted.internet import error as internet_error
+from twisted.internet.task import LoopingCall
+
+
+class VPNSignals(QtCore.QObject):
+ """
+ These are the signals that we use to let the UI know
+ about the events we are polling.
+ They are instantiated in the VPN object and passed along
+ till the VPNProcess.
+ """
+ state_changed = QtCore.Signal(dict)
+ status_changed = QtCore.Signal(dict)
+ process_finished = QtCore.Signal(int)
+
+ def __init__(self):
+ QtCore.QObject.__init__(self)
+
+
+class OpenVPNAlreadyRunning(Exception):
+ message = ("Another openvpn instance is already running, and could "
+ "not be stopped.")
+
+
+class AlienOpenVPNAlreadyRunning(Exception):
+ message = ("Another openvpn instance is already running, and could "
+ "not be stopped because it was not launched by LEAP.")
+
+
+class VPN(object):
+ """
+ This is the high-level object that the GUI is dealing with.
+ It exposes the start and terminate methods.
+
+ On start, it spawns a VPNProcess instance that will use a vpnlauncher
+ suited for the running platform and connect to the management interface
+ opened by the openvpn process, executing commands over that interface on
+ demand.
+ """
+ TERMINATE_MAXTRIES = 10
+ TERMINATE_WAIT = 1 # secs
+
+ OPENVPN_VERB = "openvpn_verb"
+
+ def __init__(self, **kwargs):
+ """
+ Instantiate empty attributes and get a copy
+ of a QObject containing the QSignals that we will pass along
+ to the VPNManager.
+ """
+ from twisted.internet import reactor
+ self._vpnproc = None
+ self._pollers = []
+ self._reactor = reactor
+ self._qtsigs = VPNSignals()
+
+ self._openvpn_verb = kwargs.get(self.OPENVPN_VERB, None)
+
+ @property
+ def qtsigs(self):
+ return self._qtsigs
+
+ def start(self, *args, **kwargs):
+ """
+ Starts the openvpn subprocess.
+
+ :param args: args to be passed to the VPNProcess
+ :type args: tuple
+
+ :param kwargs: kwargs to be passed to the VPNProcess
+ :type kwargs: dict
+ """
+ self._stop_pollers()
+ kwargs['qtsigs'] = self.qtsigs
+ kwargs['openvpn_verb'] = self._openvpn_verb
+
+ # start the main vpn subprocess
+ vpnproc = VPNProcess(*args, **kwargs)
+ #qtsigs=self.qtsigs,
+ #openvpn_verb=self._openvpn_verb)
+
+ if vpnproc.get_openvpn_process():
+ logger.info("Another vpn process is running. Will try to stop it.")
+ vpnproc.stop_if_already_running()
+
+ cmd = vpnproc.getCommand()
+ env = os.environ
+ for key, val in vpnproc.vpn_env.items():
+ env[key] = val
+
+ self._reactor.spawnProcess(vpnproc, cmd[0], cmd, env)
+ self._vpnproc = vpnproc
+
+ # add pollers for status and state
+ # this could be extended to a collection of
+ # generic watchers
+
+ poll_list = [LoopingCall(vpnproc.pollStatus),
+ LoopingCall(vpnproc.pollState)]
+ self._pollers.extend(poll_list)
+ self._start_pollers()
+
+ def _kill_if_left_alive(self, tries=0):
+ """
+ Check if the process is still alive, and sends a
+ SIGKILL after a timeout period.
+
+ :param tries: counter of tries, used in recursion
+ :type tries: int
+ """
+ from twisted.internet import reactor
+ while tries < self.TERMINATE_MAXTRIES:
+ if self._vpnproc.transport.pid is None:
+ logger.debug("Process has been happily terminated.")
+ return
+ else:
+ logger.debug("Process did not die, waiting...")
+ tries += 1
+ reactor.callLater(self.TERMINATE_WAIT,
+ self._kill_if_left_alive, tries)
+
+ # after running out of patience, we try a killProcess
+ logger.debug("Process did not died. Sending a SIGKILL.")
+ self.killit()
+
+ def killit(self):
+ """
+ Sends a kill signal to the process.
+ """
+ self._stop_pollers()
+ self._vpnproc.aborted = True
+ self._vpnproc.killProcess()
+
+ def terminate(self, shutdown=False):
+ """
+ Stops the openvpn subprocess.
+
+ Attempts to send a SIGTERM first, and after a timeout
+ it sends a SIGKILL.
+ """
+ from twisted.internet import reactor
+ self._stop_pollers()
+
+ # First we try to be polite and send a SIGTERM...
+ if self._vpnproc:
+ self._sentterm = True
+ self._vpnproc.terminate_openvpn(shutdown=shutdown)
+
+ # ...but we also trigger a countdown to be unpolite
+ # if strictly needed.
+
+ # XXX Watch out! This will fail NOW since we are running
+ # openvpn as root as a workaround for some connection issues.
+ reactor.callLater(
+ self.TERMINATE_WAIT, self._kill_if_left_alive)
+
+ def _start_pollers(self):
+ """
+ Iterate through the registered observers
+ and start the looping call for them.
+ """
+ for poller in self._pollers:
+ poller.start(VPNManager.POLL_TIME)
+
+ def _stop_pollers(self):
+ """
+ Iterate through the registered observers
+ and stop the looping calls if they are running.
+ """
+ for poller in self._pollers:
+ if poller.running:
+ poller.stop()
+ self._pollers = []
+
+
+class VPNManager(object):
+ """
+ This is a mixin that we use in the VPNProcess class.
+ Here we get together all methods related with the openvpn management
+ interface.
+
+ A copy of a QObject containing signals as attributes is passed along
+ upon initialization, and we use that object to emit signals to qt-land.
+
+ For more info about management methods::
+
+ zcat `dpkg -L openvpn | grep management`
+ """
+
+ # Timers, in secs
+ POLL_TIME = 0.5
+ CONNECTION_RETRY_TIME = 1
+
+ TS_KEY = "ts"
+ STATUS_STEP_KEY = "status_step"
+ OK_KEY = "ok"
+ IP_KEY = "ip"
+ REMOTE_KEY = "remote"
+
+ TUNTAP_READ_KEY = "tun_tap_read"
+ TUNTAP_WRITE_KEY = "tun_tap_write"
+ TCPUDP_READ_KEY = "tcp_udp_read"
+ TCPUDP_WRITE_KEY = "tcp_udp_write"
+ AUTH_READ_KEY = "auth_read"
+
+ def __init__(self, qtsigs=None):
+ """
+ Initializes the VPNManager.
+
+ :param qtsigs: a QObject containing the Qt signals used by the UI
+ to give feedback about state changes.
+ :type qtsigs: QObject
+ """
+ from twisted.internet import reactor
+ self._reactor = reactor
+ self._tn = None
+ self._qtsigs = qtsigs
+ self._aborted = False
+
+ @property
+ def qtsigs(self):
+ return self._qtsigs
+
+ @property
+ def aborted(self):
+ return self._aborted
+
+ @aborted.setter
+ def aborted(self, value):
+ self._aborted = value
+
+ def _seek_to_eof(self):
+ """
+ Read as much as available. Position seek pointer to end of stream
+ """
+ try:
+ self._tn.read_eager()
+ except EOFError:
+ logger.debug("Could not read from socket. Assuming it died.")
+ return
+
+ def _send_command(self, command, until=b"END"):
+ """
+ Sends a command to the telnet connection and reads until END
+ is reached.
+
+ :param command: command to send
+ :type command: str
+
+ :param until: byte delimiter string for reading command output
+ :type until: byte str
+
+ :return: response read
+ :rtype: list
+ """
+ leap_assert(self._tn, "We need a tn connection!")
+
+ try:
+ self._tn.write("%s\n" % (command,))
+ buf = self._tn.read_until(until, 2)
+ self._seek_to_eof()
+ blist = buf.split('\r\n')
+ if blist[-1].startswith(until):
+ del blist[-1]
+ return blist
+ else:
+ return []
+
+ except socket.error:
+ # XXX should get a counter and repeat only
+ # after mod X times.
+ logger.warning('socket error (command was: "%s")' % (command,))
+ self._close_management_socket(announce=False)
+ logger.debug('trying to connect to management again')
+ self.try_to_connect_to_management(max_retries=5)
+ return []
+
+ # XXX should move this to a errBack!
+ except Exception as e:
+ logger.warning("Error sending command %s: %r" %
+ (command, e))
+ return []
+
+ def _close_management_socket(self, announce=True):
+ """
+ Close connection to openvpn management interface.
+ """
+ logger.debug('closing socket')
+ if announce:
+ self._tn.write("quit\n")
+ self._tn.read_all()
+ self._tn.get_socket().close()
+ self._tn = None
+
+ def _connect_management(self, socket_host, socket_port):
+ """
+ Connects to the management interface on the specified
+ socket_host socket_port.
+
+ :param socket_host: either socket path (unix) or socket IP
+ :type socket_host: str
+
+ :param socket_port: either string "unix" if it's a unix
+ socket, or port otherwise
+ :type socket_port: str
+ """
+ if self.is_connected():
+ self._close_management_socket()
+
+ try:
+ self._tn = UDSTelnet(socket_host, socket_port)
+
+ # XXX make password optional
+ # specially for win. we should generate
+ # the pass on the fly when invoking manager
+ # from conductor
+
+ # self.tn.read_until('ENTER PASSWORD:', 2)
+ # self.tn.write(self.password + '\n')
+ # self.tn.read_until('SUCCESS:', 2)
+ if self._tn:
+ self._tn.read_eager()
+
+ # XXX move this to the Errback
+ except Exception as e:
+ logger.warning("Could not connect to OpenVPN yet: %r" % (e,))
+ self._tn = None
+
+ def _connectCb(self, *args):
+ """
+ Callback for connection.
+
+ :param args: not used
+ """
+ if self._tn:
+ logger.info('Connected to management')
+ else:
+ logger.debug('Cannot connect to management...')
+
+ def _connectErr(self, failure):
+ """
+ Errorback for connection.
+
+ :param failure: Failure
+ """
+ logger.warning(failure)
+
+ def connect_to_management(self, host, port):
+ """
+ Connect to a management interface.
+
+ :param host: the host of the management interface
+ :type host: str
+
+ :param port: the port of the management interface
+ :type port: str
+
+ :returns: a deferred
+ """
+ self.connectd = defer.maybeDeferred(
+ self._connect_management, host, port)
+ self.connectd.addCallbacks(self._connectCb, self._connectErr)
+ return self.connectd
+
+ def is_connected(self):
+ """
+ Returns the status of the management interface.
+
+ :returns: True if connected, False otherwise
+ :rtype: bool
+ """
+ return True if self._tn else False
+
+ def try_to_connect_to_management(self, retry=0, max_retries=None):
+ """
+ Attempts to connect to a management interface, and retries
+ after CONNECTION_RETRY_TIME if not successful.
+
+ :param retry: number of the retry
+ :type retry: int
+ """
+ if max_retries and retry > max_retries:
+ logger.warning("Max retries reached while attempting to connect "
+ "to management. Aborting.")
+ self.aborted = True
+ return
+
+ # _alive flag is set in the VPNProcess class.
+ if not self._alive:
+ logger.debug('Tried to connect to management but process is '
+ 'not alive.')
+ return
+ logger.debug('trying to connect to management')
+ if not self.aborted and not self.is_connected():
+ self.connect_to_management(self._socket_host, self._socket_port)
+ self._reactor.callLater(
+ self.CONNECTION_RETRY_TIME,
+ self.try_to_connect_to_management, retry + 1)
+
+ def _parse_state_and_notify(self, output):
+ """
+ Parses the output of the state command and emits state_changed
+ signal when the state changes.
+
+ :param output: list of lines that the state command printed as
+ its output
+ :type output: list
+ """
+ for line in output:
+ stripped = line.strip()
+ if stripped == "END":
+ continue
+ parts = stripped.split(",")
+ if len(parts) < 5:
+ continue
+ ts, status_step, ok, ip, remote = parts
+
+ state_dict = {
+ self.TS_KEY: ts,
+ self.STATUS_STEP_KEY: status_step,
+ self.OK_KEY: ok,
+ self.IP_KEY: ip,
+ self.REMOTE_KEY: remote
+ }
+
+ if state_dict != self._last_state:
+ self.qtsigs.state_changed.emit(state_dict)
+ self._last_state = state_dict
+
+ def _parse_status_and_notify(self, output):
+ """
+ Parses the output of the status command and emits
+ status_changed signal when the status changes.
+
+ :param output: list of lines that the status command printed
+ as its output
+ :type output: list
+ """
+ tun_tap_read = ""
+ tun_tap_write = ""
+ tcp_udp_read = ""
+ tcp_udp_write = ""
+ auth_read = ""
+ for line in output:
+ stripped = line.strip()
+ if stripped.endswith("STATISTICS") or stripped == "END":
+ continue
+ parts = stripped.split(",")
+ if len(parts) < 2:
+ continue
+ if parts[0].strip() == "TUN/TAP read bytes":
+ tun_tap_read = parts[1]
+ elif parts[0].strip() == "TUN/TAP write bytes":
+ tun_tap_write = parts[1]
+ elif parts[0].strip() == "TCP/UDP read bytes":
+ tcp_udp_read = parts[1]
+ elif parts[0].strip() == "TCP/UDP write bytes":
+ tcp_udp_write = parts[1]
+ elif parts[0].strip() == "Auth read bytes":
+ auth_read = parts[1]
+
+ status_dict = {
+ self.TUNTAP_READ_KEY: tun_tap_read,
+ self.TUNTAP_WRITE_KEY: tun_tap_write,
+ self.TCPUDP_READ_KEY: tcp_udp_read,
+ self.TCPUDP_WRITE_KEY: tcp_udp_write,
+ self.AUTH_READ_KEY: auth_read
+ }
+
+ if status_dict != self._last_status:
+ self.qtsigs.status_changed.emit(status_dict)
+ self._last_status = status_dict
+
+ def get_state(self):
+ """
+ Notifies the gui of the output of the state command over
+ the openvpn management interface.
+ """
+ if self.is_connected():
+ return self._parse_state_and_notify(self._send_command("state"))
+
+ def get_status(self):
+ """
+ Notifies the gui of the output of the status command over
+ the openvpn management interface.
+ """
+ if self.is_connected():
+ return self._parse_status_and_notify(self._send_command("status"))
+
+ @property
+ def vpn_env(self):
+ """
+ Return a dict containing the vpn environment to be used.
+ """
+ return self._launcher.get_vpn_env(self._providerconfig)
+
+ def terminate_openvpn(self, shutdown=False):
+ """
+ Attempts to terminate openvpn by sending a SIGTERM.
+ """
+ if self.is_connected():
+ self._send_command("signal SIGTERM")
+ if shutdown:
+ self._cleanup_tempfiles()
+
+ def _cleanup_tempfiles(self):
+ """
+ Remove all temporal files we might have left behind.
+
+ Iif self.port is 'unix', we have created a temporal socket path that,
+ under normal circumstances, we should be able to delete.
+ """
+ if self._socket_port == "unix":
+ logger.debug('cleaning socket file temp folder')
+ tempfolder = first(os.path.split(self._socket_host))
+ if tempfolder and os.path.isdir(tempfolder):
+ try:
+ shutil.rmtree(tempfolder)
+ except OSError:
+ logger.error('could not delete tmpfolder %s' % tempfolder)
+
+ def get_openvpn_process(self):
+ """
+ Looks for openvpn instances running.
+
+ :rtype: process
+ """
+ openvpn_process = None
+ for p in psutil.process_iter():
+ try:
+ # XXX Not exact!
+ # Will give false positives.
+ # we should check that cmdline BEGINS
+ # with openvpn or with our wrapper
+ # (pkexec / osascript / whatever)
+
+ # This needs more work, see #3268, but for the moment
+ # we need to be able to filter out arguments in the form
+ # --openvpn-foo, since otherwise we are shooting ourselves
+ # in the feet.
+ if any(map(lambda s: s.startswith("openvpn"), p.cmdline)):
+ openvpn_process = p
+ break
+ except psutil.error.AccessDenied:
+ pass
+ return openvpn_process
+
+ def stop_if_already_running(self):
+ """
+ Checks if VPN is already running and tries to stop it.
+
+ Might raise OpenVPNAlreadyRunning.
+
+ :return: True if stopped, False otherwise
+
+ """
+ process = self.get_openvpn_process()
+ if not process:
+ logger.debug('Could not find openvpn process while '
+ 'trying to stop it.')
+ return
+
+ logger.debug("OpenVPN is already running, trying to stop it...")
+ cmdline = process.cmdline
+
+ manag_flag = "--management"
+ if isinstance(cmdline, list) and manag_flag in cmdline:
+ # we know that our invocation has this distinctive fragment, so
+ # we use this fingerprint to tell other invocations apart.
+ # this might break if we change the configuration path in the
+ # launchers
+ smellslikeleap = lambda s: "leap" in s and "providers" in s
+
+ if not any(map(smellslikeleap, cmdline)):
+ logger.debug("We cannot stop this instance since we do not "
+ "recognise it as a leap invocation.")
+ raise AlienOpenVPNAlreadyRunning
+
+ try:
+ index = cmdline.index(manag_flag)
+ host = cmdline[index + 1]
+ port = cmdline[index + 2]
+ logger.debug("Trying to connect to %s:%s"
+ % (host, port))
+ self.connect_to_management(host, port)
+
+ # XXX this has a problem with connections to different
+ # remotes. So the reconnection will only work when we are
+ # terminating instances left running for the same provider.
+ # If we are killing an openvpn instance configured for another
+ # provider, we will get:
+ # TLS Error: local/remote TLS keys are out of sync
+ # However, that should be a rare case right now.
+ self._send_command("signal SIGTERM")
+ self._close_management_socket(announce=True)
+ except Exception as e:
+ logger.warning("Problem trying to terminate OpenVPN: %r"
+ % (e,))
+ else:
+ logger.debug("Could not find the expected openvpn command line.")
+
+ process = self.get_openvpn_process()
+ if process is None:
+ logger.debug("Successfully finished already running "
+ "openvpn process.")
+ return True
+ else:
+ logger.warning("Unable to terminate OpenVPN")
+ raise OpenVPNAlreadyRunning
+
+
+class VPNProcess(protocol.ProcessProtocol, VPNManager):
+ """
+ A ProcessProtocol class that can be used to spawn a process that will
+ launch openvpn and connect to its management interface to control it
+ programmatically.
+ """
+
+ def __init__(self, eipconfig, providerconfig, socket_host, socket_port,
+ qtsigs, openvpn_verb):
+ """
+ :param eipconfig: eip configuration object
+ :type eipconfig: EIPConfig
+
+ :param providerconfig: provider specific configuration
+ :type providerconfig: ProviderConfig
+
+ :param socket_host: either socket path (unix) or socket IP
+ :type socket_host: str
+
+ :param socket_port: either string "unix" if it's a unix
+ socket, or port otherwise
+ :type socket_port: str
+
+ :param qtsigs: a QObject containing the Qt signals used to notify the
+ UI.
+ :type qtsigs: QObject
+
+ :param openvpn_verb: the desired level of verbosity in the
+ openvpn invocation
+ :type openvpn_verb: int
+ """
+ VPNManager.__init__(self, qtsigs=qtsigs)
+ leap_assert_type(eipconfig, EIPConfig)
+ leap_assert_type(providerconfig, ProviderConfig)
+ leap_assert_type(qtsigs, QtCore.QObject)
+
+ #leap_assert(not self.isRunning(), "Starting process more than once!")
+
+ self._eipconfig = eipconfig
+ self._providerconfig = providerconfig
+ self._socket_host = socket_host
+ self._socket_port = socket_port
+
+ self._launcher = get_platform_launcher()
+
+ self._last_state = None
+ self._last_status = None
+ self._alive = False
+
+ self._openvpn_verb = openvpn_verb
+
+ # processProtocol methods
+
+ def connectionMade(self):
+ """
+ Called when the connection is made.
+
+ .. seeAlso: `http://twistedmatrix.com/documents/13.0.0/api/twisted.internet.protocol.ProcessProtocol.html` # noqa
+ """
+ self._alive = True
+ self.aborted = False
+ self.try_to_connect_to_management(max_retries=10)
+
+ def outReceived(self, data):
+ """
+ Called when new data is available on stdout.
+
+ :param data: the data read on stdout
+
+ .. seeAlso: `http://twistedmatrix.com/documents/13.0.0/api/twisted.internet.protocol.ProcessProtocol.html` # noqa
+ """
+ # truncate the newline
+ # should send this to the logging window
+ vpnlog.info(data[:-1])
+
+ def processExited(self, reason):
+ """
+ Called when the child process exits.
+
+ .. seeAlso: `http://twistedmatrix.com/documents/13.0.0/api/twisted.internet.protocol.ProcessProtocol.html` # noqa
+ """
+ exit_code = reason.value.exitCode
+ if isinstance(exit_code, int):
+ logger.debug("processExited, status %d" % (exit_code,))
+ self.qtsigs.process_finished.emit(exit_code)
+ self._alive = False
+
+ def processEnded(self, reason):
+ """
+ Called when the child process exits and all file descriptors associated
+ with it have been closed.
+
+ .. seeAlso: `http://twistedmatrix.com/documents/13.0.0/api/twisted.internet.protocol.ProcessProtocol.html` # noqa
+ """
+ exit_code = reason.value.exitCode
+ if isinstance(exit_code, int):
+ logger.debug("processEnded, status %d" % (exit_code,))
+
+ # polling
+
+ def pollStatus(self):
+ """
+ Polls connection status.
+ """
+ if self._alive:
+ self.get_status()
+
+ def pollState(self):
+ """
+ Polls connection state.
+ """
+ if self._alive:
+ self.get_state()
+
+ # launcher
+
+ def getCommand(self):
+ """
+ Gets the vpn command from the aproppriate launcher.
+
+ Might throw: VPNLauncherException, OpenVPNNotFoundException.
+ """
+ cmd = self._launcher.get_vpn_command(
+ eipconfig=self._eipconfig,
+ providerconfig=self._providerconfig,
+ socket_host=self._socket_host,
+ socket_port=self._socket_port,
+ openvpn_verb=self._openvpn_verb)
+ return map(str, cmd)
+
+ # shutdown
+
+ def killProcess(self):
+ """
+ Sends the KILL signal to the running process.
+ """
+ try:
+ self.transport.signalProcess('KILL')
+ except internet_error.ProcessExitedAlready:
+ logger.debug('Process Exited Already')
diff --git a/src/leap/bitmask/services/mail/__init__.py b/src/leap/bitmask/services/mail/__init__.py
new file mode 100644
index 00000000..e69de29b
--- /dev/null
+++ b/src/leap/bitmask/services/mail/__init__.py
diff --git a/src/leap/bitmask/services/mail/imap.py b/src/leap/bitmask/services/mail/imap.py
new file mode 100644
index 00000000..cf93c60e
--- /dev/null
+++ b/src/leap/bitmask/services/mail/imap.py
@@ -0,0 +1,42 @@
+# -*- coding: utf-8 -*-
+# imap.py
+# Copyright (C) 2013 LEAP
+#
+# 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/>.
+"""
+Initialization of imap service
+"""
+import logging
+#import sys
+
+from leap.mail.imap.service import imap
+#from twisted.python import log
+
+logger = logging.getLogger(__name__)
+
+
+def start_imap_service(*args, **kwargs):
+ """
+ Initializes and run imap service.
+
+ :returns: twisted.internet.task.LoopingCall instance
+ """
+ logger.debug('Launching imap service')
+
+ # Uncomment the next two lines to get a separate debugging log
+ # TODO handle this by a separate flag.
+ #log.startLogging(open('/tmp/leap-imap.log', 'w'))
+ #log.startLogging(sys.stdout)
+
+ return imap.run_service(*args, **kwargs)
diff --git a/src/leap/bitmask/services/mail/smtpbootstrapper.py b/src/leap/bitmask/services/mail/smtpbootstrapper.py
new file mode 100644
index 00000000..0e83424c
--- /dev/null
+++ b/src/leap/bitmask/services/mail/smtpbootstrapper.py
@@ -0,0 +1,139 @@
+# -*- coding: utf-8 -*-
+# smtpbootstrapper.py
+# Copyright (C) 2013 LEAP
+#
+# 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/>.
+
+"""
+SMTP bootstrapping
+"""
+
+import logging
+import os
+
+from PySide import QtCore
+
+from leap.bitmask.config.providerconfig import ProviderConfig
+from leap.bitmask.crypto.srpauth import SRPAuth
+from leap.bitmask.util.request_helpers import get_content
+from leap.bitmask.services.abstractbootstrapper import AbstractBootstrapper
+from leap.common.check import leap_assert, leap_assert_type
+from leap.common.files import get_mtime
+
+logger = logging.getLogger(__name__)
+
+
+class SMTPBootstrapper(AbstractBootstrapper):
+ """
+ SMTP init procedure
+ """
+
+ # All dicts returned are of the form
+ # {"passed": bool, "error": str}
+ download_config = QtCore.Signal(dict)
+
+ def __init__(self):
+ AbstractBootstrapper.__init__(self)
+
+ self._provider_config = None
+ self._smtp_config = None
+ self._download_if_needed = False
+
+ def _download_config(self, *args):
+ """
+ Downloads the SMTP config for the given provider
+ """
+
+ leap_assert(self._provider_config,
+ "We need a provider configuration!")
+
+ logger.debug("Downloading SMTP config for %s" %
+ (self._provider_config.get_domain(),))
+
+ headers = {}
+ mtime = get_mtime(os.path.join(self._smtp_config
+ .get_path_prefix(),
+ "leap",
+ "providers",
+ self._provider_config.get_domain(),
+ "smtp-service.json"))
+
+ if self._download_if_needed and mtime:
+ headers['if-modified-since'] = mtime
+
+ api_version = self._provider_config.get_api_version()
+
+ # there is some confusion with this uri,
+ config_uri = "%s/%s/config/smtp-service.json" % (
+ self._provider_config.get_api_uri(), api_version)
+
+ logger.debug('Downloading SMTP config from: %s' % config_uri)
+
+ srp_auth = SRPAuth(self._provider_config)
+ session_id = srp_auth.get_session_id()
+ cookies = None
+ if session_id:
+ cookies = {"_session_id": session_id}
+
+ res = self._session.get(config_uri,
+ verify=self._provider_config
+ .get_ca_cert_path(),
+ headers=headers,
+ cookies=cookies)
+ res.raise_for_status()
+
+ self._smtp_config.set_api_version(api_version)
+
+ # Not modified
+ if res.status_code == 304:
+ logger.debug("SMTP definition has not been modified")
+ self._smtp_config.load(os.path.join(
+ "leap", "providers",
+ self._provider_config.get_domain(),
+ "smtp-service.json"))
+ else:
+ smtp_definition, mtime = get_content(res)
+
+ self._smtp_config.load(data=smtp_definition, mtime=mtime)
+ self._smtp_config.save(["leap",
+ "providers",
+ self._provider_config.get_domain(),
+ "smtp-service.json"])
+
+ def run_smtp_setup_checks(self,
+ provider_config,
+ smtp_config,
+ download_if_needed=False):
+ """
+ Starts the checks needed for a new smtp setup
+
+ :param provider_config: Provider configuration
+ :type provider_config: ProviderConfig
+ :param smtp_config: SMTP configuration to populate
+ :type smtp_config: SMTPConfig
+ :param download_if_needed: True if it should check for mtime
+ for the file
+ :type download_if_needed: bool
+ """
+ leap_assert_type(provider_config, ProviderConfig)
+
+ self._provider_config = provider_config
+ self._smtp_config = smtp_config
+ self._download_if_needed = download_if_needed
+
+ cb_chain = [
+ (self._download_config, self.download_config),
+ ]
+
+ self.addCallbackChain(cb_chain)
diff --git a/src/leap/bitmask/services/mail/smtpconfig.py b/src/leap/bitmask/services/mail/smtpconfig.py
new file mode 100644
index 00000000..20041c30
--- /dev/null
+++ b/src/leap/bitmask/services/mail/smtpconfig.py
@@ -0,0 +1,49 @@
+# -*- coding: utf-8 -*-
+# smtpconfig.py
+# Copyright (C) 2013 LEAP
+#
+# 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/>.
+
+"""
+SMTP configuration
+"""
+import logging
+
+from leap.bitmask.services.mail.smtpspec import get_schema
+from leap.common.config.baseconfig import BaseConfig
+
+logger = logging.getLogger(__name__)
+
+
+class SMTPConfig(BaseConfig):
+ """
+ SMTP configuration abstraction class
+ """
+
+ def __init__(self):
+ BaseConfig.__init__(self)
+
+ def _get_schema(self):
+ """
+ Returns the schema corresponding to the version given.
+
+ :rtype: dict or None if the version is not supported.
+ """
+ return get_schema(self._api_version)
+
+ def get_hosts(self):
+ return self._safe_get_value("hosts")
+
+ def get_locations(self):
+ return self._safe_get_value("locations")
diff --git a/src/leap/bitmask/services/mail/smtpspec.py b/src/leap/bitmask/services/mail/smtpspec.py
new file mode 100644
index 00000000..ff9d1bf8
--- /dev/null
+++ b/src/leap/bitmask/services/mail/smtpspec.py
@@ -0,0 +1,70 @@
+# -*- coding: utf-8 -*-
+# smtpspec.py
+# Copyright (C) 2013 LEAP
+#
+# 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/>.
+
+# Schemas dict
+# To add a schema for a version you should follow the form:
+# { '1': schema_v1, '2': schema_v2, ... etc }
+# so for instance, to add the '2' version, you should do:
+# smtp_config_spec['2'] = schema_v2
+smtp_config_spec = {}
+
+smtp_config_spec['1'] = {
+ 'description': 'sample smtp service config',
+ 'type': 'object',
+ 'properties': {
+ 'serial': {
+ 'type': int,
+ 'default': 1,
+ 'required': ["True"]
+ },
+ 'version': {
+ 'type': int,
+ 'default': 1,
+ 'required': ["True"]
+ },
+ 'hosts': {
+ 'type': dict,
+ 'default': {
+ "walrus": {
+ "hostname": "someprovider",
+ "ip_address": "1.1.1.1",
+ "port": 1111
+ },
+ },
+ },
+ 'locations': {
+ 'type': dict,
+ 'default': {
+ "locations": {
+
+ }
+ }
+ }
+ }
+}
+
+
+def get_schema(version):
+ """
+ Returns the schema corresponding to the version given.
+
+ :param version: the version of the schema to get.
+ :type version: str
+ :rtype: dict or None if the version is not supported.
+ """
+ schema = smtp_config_spec.get(version, None)
+ return schema
diff --git a/src/leap/bitmask/services/soledad/__init__.py b/src/leap/bitmask/services/soledad/__init__.py
new file mode 100644
index 00000000..e69de29b
--- /dev/null
+++ b/src/leap/bitmask/services/soledad/__init__.py
diff --git a/src/leap/bitmask/services/soledad/soledadbootstrapper.py b/src/leap/bitmask/services/soledad/soledadbootstrapper.py
new file mode 100644
index 00000000..fba74d60
--- /dev/null
+++ b/src/leap/bitmask/services/soledad/soledadbootstrapper.py
@@ -0,0 +1,265 @@
+# -*- coding: utf-8 -*-
+# soledadbootstrapper.py
+# Copyright (C) 2013 LEAP
+#
+# 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/>.
+
+"""
+Soledad bootstrapping
+"""
+
+import logging
+import os
+
+from PySide import QtCore
+from u1db import errors as u1db_errors
+
+from leap.bitmask.config.providerconfig import ProviderConfig
+from leap.bitmask.crypto.srpauth import SRPAuth
+from leap.bitmask.services.abstractbootstrapper import AbstractBootstrapper
+from leap.bitmask.services.soledad.soledadconfig import SoledadConfig
+from leap.bitmask.util.request_helpers import get_content
+from leap.common.check import leap_assert, leap_assert_type
+from leap.common.files import get_mtime
+from leap.keymanager import KeyManager, openpgp
+from leap.keymanager.errors import KeyNotFound
+from leap.soledad import Soledad
+
+logger = logging.getLogger(__name__)
+
+
+class SoledadBootstrapper(AbstractBootstrapper):
+ """
+ Soledad init procedure
+ """
+
+ SOLEDAD_KEY = "soledad"
+ KEYMANAGER_KEY = "keymanager"
+
+ PUBKEY_KEY = "user[public_key]"
+
+ # All dicts returned are of the form
+ # {"passed": bool, "error": str}
+ download_config = QtCore.Signal(dict)
+ gen_key = QtCore.Signal(dict)
+
+ def __init__(self):
+ AbstractBootstrapper.__init__(self)
+
+ self._provider_config = None
+ self._soledad_config = None
+ self._keymanager = None
+ self._download_if_needed = False
+ self._user = ""
+ self._password = ""
+ self._soledad = None
+
+ @property
+ def keymanager(self):
+ return self._keymanager
+
+ @property
+ def soledad(self):
+ return self._soledad
+
+ def _load_and_sync_soledad(self, srp_auth):
+ """
+ Once everthing is in the right place, we instantiate and sync
+ Soledad
+
+ :param srp_auth: SRPAuth object used
+ :type srp_auth: SRPAuth
+ """
+ uuid = srp_auth.get_uid()
+
+ prefix = os.path.join(self._soledad_config.get_path_prefix(),
+ "leap", "soledad")
+ secrets_path = "%s/%s.secret" % (prefix, uuid)
+ local_db_path = "%s/%s.db" % (prefix, uuid)
+
+ # TODO: Select server based on timezone (issue #3308)
+ server_dict = self._soledad_config.get_hosts()
+
+ if server_dict.keys():
+ selected_server = server_dict[server_dict.keys()[0]]
+ server_url = "https://%s:%s/user-%s" % (
+ selected_server["hostname"],
+ selected_server["port"],
+ uuid)
+
+ logger.debug("Using soledad server url: %s" % (server_url,))
+
+ cert_file = self._provider_config.get_ca_cert_path()
+
+ # TODO: If selected server fails, retry with another host
+ # (issue #3309)
+ try:
+ self._soledad = Soledad(
+ uuid,
+ self._password.encode("utf-8"),
+ secrets_path=secrets_path,
+ local_db_path=local_db_path,
+ server_url=server_url,
+ cert_file=cert_file,
+ auth_token=srp_auth.get_token())
+ self._soledad.sync()
+ except u1db_errors.Unauthorized:
+ logger.error("Error while initializing soledad.")
+ else:
+ raise Exception("No soledad server found")
+
+ def _download_config(self):
+ """
+ Downloads the Soledad config for the given provider
+ """
+
+ leap_assert(self._provider_config,
+ "We need a provider configuration!")
+
+ logger.debug("Downloading Soledad config for %s" %
+ (self._provider_config.get_domain(),))
+
+ self._soledad_config = SoledadConfig()
+
+ headers = {}
+ mtime = get_mtime(
+ os.path.join(
+ self._soledad_config.get_path_prefix(),
+ "leap", "providers",
+ self._provider_config.get_domain(),
+ "soledad-service.json"))
+
+ if self._download_if_needed and mtime:
+ headers['if-modified-since'] = mtime
+
+ api_version = self._provider_config.get_api_version()
+
+ # there is some confusion with this uri,
+ config_uri = "%s/%s/config/soledad-service.json" % (
+ self._provider_config.get_api_uri(),
+ api_version)
+ logger.debug('Downloading soledad config from: %s' % config_uri)
+
+ srp_auth = SRPAuth(self._provider_config)
+ session_id = srp_auth.get_session_id()
+ cookies = None
+ if session_id:
+ cookies = {"_session_id": session_id}
+
+ res = self._session.get(config_uri,
+ verify=self._provider_config
+ .get_ca_cert_path(),
+ headers=headers,
+ cookies=cookies)
+ res.raise_for_status()
+
+ self._soledad_config.set_api_version(api_version)
+
+ # Not modified
+ if res.status_code == 304:
+ logger.debug("Soledad definition has not been modified")
+ self._soledad_config.load(
+ os.path.join(
+ "leap", "providers",
+ self._provider_config.get_domain(),
+ "soledad-service.json"))
+ else:
+ soledad_definition, mtime = get_content(res)
+
+ self._soledad_config.load(data=soledad_definition, mtime=mtime)
+ self._soledad_config.save(["leap",
+ "providers",
+ self._provider_config.get_domain(),
+ "soledad-service.json"])
+
+ self._load_and_sync_soledad(srp_auth)
+
+ def _gen_key(self, _):
+ """
+ Generates the key pair if needed, uploads it to the webapp and
+ nickserver
+ """
+ leap_assert(self._provider_config,
+ "We need a provider configuration!")
+
+ address = "%s@%s" % (self._user, self._provider_config.get_domain())
+
+ logger.debug("Retrieving key for %s" % (address,))
+
+ srp_auth = SRPAuth(self._provider_config)
+
+ # TODO: Fix for Windows
+ gpgbin = "/usr/bin/gpg"
+
+ if self._standalone:
+ gpgbin = os.path.join(self._provider_config.get_path_prefix(),
+ "..", "apps", "mail", "gpg")
+
+ self._keymanager = KeyManager(
+ address,
+ "https://nicknym.%s:6425" % (self._provider_config.get_domain(),),
+ self._soledad,
+ #token=srp_auth.get_token(), # TODO: enable token usage
+ session_id=srp_auth.get_session_id(),
+ ca_cert_path=self._provider_config.get_ca_cert_path(),
+ api_uri=self._provider_config.get_api_uri(),
+ api_version=self._provider_config.get_api_version(),
+ uid=srp_auth.get_uid(),
+ gpgbinary=gpgbin)
+ try:
+ self._keymanager.get_key(address, openpgp.OpenPGPKey,
+ private=True, fetch_remote=False)
+ except KeyNotFound:
+ logger.debug("Key not found. Generating key for %s" % (address,))
+ self._keymanager.gen_key(openpgp.OpenPGPKey)
+ self._keymanager.send_key(openpgp.OpenPGPKey)
+ logger.debug("Key generated successfully.")
+
+ def run_soledad_setup_checks(self,
+ provider_config,
+ user,
+ password,
+ download_if_needed=False,
+ standalone=False):
+ """
+ Starts the checks needed for a new soledad setup
+
+ :param provider_config: Provider configuration
+ :type provider_config: ProviderConfig
+ :param user: User's login
+ :type user: str
+ :param password: User's password
+ :type password: str
+ :param download_if_needed: If True, it will only download
+ files if the have changed since the
+ time it was previously downloaded.
+ :type download_if_needed: bool
+ :param standalone: If True, it'll look for paths inside the
+ bundle (like for gpg)
+ :type standalone: bool
+ """
+ leap_assert_type(provider_config, ProviderConfig)
+
+ self._provider_config = provider_config
+ self._download_if_needed = download_if_needed
+ self._user = user
+ self._password = password
+ self._standalone = standalone
+
+ cb_chain = [
+ (self._download_config, self.download_config),
+ (self._gen_key, self.gen_key)
+ ]
+
+ self.addCallbackChain(cb_chain)
diff --git a/src/leap/bitmask/services/soledad/soledadconfig.py b/src/leap/bitmask/services/soledad/soledadconfig.py
new file mode 100644
index 00000000..7ed21f77
--- /dev/null
+++ b/src/leap/bitmask/services/soledad/soledadconfig.py
@@ -0,0 +1,49 @@
+# -*- coding: utf-8 -*-
+# soledadconfig.py
+# Copyright (C) 2013 LEAP
+#
+# 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/>.
+
+"""
+Soledad configuration
+"""
+import logging
+
+from leap.bitmask.services.soledad.soledadspec import get_schema
+from leap.common.config.baseconfig import BaseConfig
+
+logger = logging.getLogger(__name__)
+
+
+class SoledadConfig(BaseConfig):
+ """
+ Soledad configuration abstraction class
+ """
+
+ def __init__(self):
+ BaseConfig.__init__(self)
+
+ def _get_schema(self):
+ """
+ Returns the schema corresponding to the version given.
+
+ :rtype: dict or None if the version is not supported.
+ """
+ return get_schema(self._api_version)
+
+ def get_hosts(self):
+ return self._safe_get_value("hosts")
+
+ def get_locations(self):
+ return self._safe_get_value("locations")
diff --git a/src/leap/bitmask/services/soledad/soledadspec.py b/src/leap/bitmask/services/soledad/soledadspec.py
new file mode 100644
index 00000000..111175dd
--- /dev/null
+++ b/src/leap/bitmask/services/soledad/soledadspec.py
@@ -0,0 +1,76 @@
+# -*- coding: utf-8 -*-
+# soledadspec.py
+# Copyright (C) 2013 LEAP
+#
+# 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/>.
+
+# Schemas dict
+# To add a schema for a version you should follow the form:
+# { '1': schema_v1, '2': schema_v2, ... etc }
+# so for instance, to add the '2' version, you should do:
+# soledad_config_spec['2'] = schema_v2
+soledad_config_spec = {}
+
+soledad_config_spec['1'] = {
+ 'description': 'sample soledad service config',
+ 'type': 'object',
+ 'properties': {
+ 'serial': {
+ 'type': int,
+ 'default': 1,
+ 'required': ["True"]
+ },
+ 'version': {
+ 'type': int,
+ 'default': 1,
+ 'required': ["True"]
+ },
+ 'hosts': {
+ 'type': dict,
+ 'default': {
+ "python": {
+ "hostname": "someprovider",
+ "ip_address": "1.1.1.1",
+ "location": "loc",
+ "port": 1111
+ },
+ },
+ },
+ 'locations': {
+ 'type': dict,
+ 'default': {
+ "locations": {
+ "ankara": {
+ "country_code": "TR",
+ "hemisphere": "N",
+ "name": "loc",
+ "timezone": "+0"
+ }
+ }
+ }
+ }
+ }
+}
+
+
+def get_schema(version):
+ """
+ Returns the schema corresponding to the version given.
+
+ :param version: the version of the schema to get.
+ :type version: str
+ :rtype: dict or None if the version is not supported.
+ """
+ schema = soledad_config_spec.get(version, None)
+ return schema
diff --git a/src/leap/bitmask/services/tests/__init__.py b/src/leap/bitmask/services/tests/__init__.py
new file mode 100644
index 00000000..e69de29b
--- /dev/null
+++ b/src/leap/bitmask/services/tests/__init__.py
diff --git a/src/leap/bitmask/services/tests/test_abstractbootstrapper.py b/src/leap/bitmask/services/tests/test_abstractbootstrapper.py
new file mode 100644
index 00000000..3ac126ac
--- /dev/null
+++ b/src/leap/bitmask/services/tests/test_abstractbootstrapper.py
@@ -0,0 +1,197 @@
+## -*- coding: utf-8 -*-
+# test_abstrctbootstrapper.py
+# Copyright (C) 2013 LEAP
+#
+# 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 the Abstract Boostrapper functionality
+"""
+
+import mock
+
+from PySide import QtCore
+
+from nose.twistedtools import deferred
+
+from leap.bitmask.services.abstractbootstrapper import AbstractBootstrapper
+from leap.bitmask.util.pyside_tests_helper import \
+ UsesQApplication, BasicPySlotCase
+
+
+class TesterBootstrapper(AbstractBootstrapper):
+ test_signal1 = QtCore.Signal(dict)
+ test_signal2 = QtCore.Signal(dict)
+ test_signal3 = QtCore.Signal(dict)
+
+ ERROR_MSG = "This is a test error msg"
+
+ def _check_that_passes(self, *args):
+ pass
+
+ def _second_check_that_passes(self, *args):
+ pass
+
+ def _check_that_fails(self, *args):
+ raise Exception(self.ERROR_MSG)
+
+ def run_checks_pass(self):
+ cb_chain = [
+ (self._check_that_passes, self.test_signal1),
+ (self._second_check_that_passes, self.test_signal2),
+ ]
+ return self.addCallbackChain(cb_chain)
+
+ def run_second_checks_pass(self):
+ cb_chain = [
+ (self._check_that_passes, None),
+ ]
+ return self.addCallbackChain(cb_chain)
+
+ def run_checks_fail(self):
+ cb_chain = [
+ (self._check_that_passes, self.test_signal1),
+ (self._check_that_fails, self.test_signal2)
+ ]
+ return self.addCallbackChain(cb_chain)
+
+ def run_second_checks_fail(self):
+ cb_chain = [
+ (self._check_that_passes, self.test_signal1),
+ (self._check_that_fails, self.test_signal2),
+ (self._second_check_that_passes, self.test_signal1)
+ ]
+ return self.addCallbackChain(cb_chain)
+
+ def run_third_checks_fail(self):
+ cb_chain = [
+ (self._check_that_passes, self.test_signal1),
+ (self._check_that_fails, None)
+ ]
+ return self.addCallbackChain(cb_chain)
+
+
+class AbstractBootstrapperTest(UsesQApplication, BasicPySlotCase):
+ def setUp(self):
+ UsesQApplication.setUp(self)
+ BasicPySlotCase.setUp(self)
+
+ self.tbt = TesterBootstrapper()
+ self.called1 = self.called2 = 0
+
+ @deferred()
+ def test_all_checks_executed_once(self):
+ self.tbt._check_that_passes = mock.MagicMock()
+ self.tbt._second_check_that_passes = mock.MagicMock()
+
+ d = self.tbt.run_checks_pass()
+
+ def check(*args):
+ self.tbt._check_that_passes.assert_called_once_with()
+ self.tbt._second_check_that_passes.\
+ assert_called_once_with(None)
+
+ d.addCallback(check)
+ return d
+
+ #######################################################################
+ # Dummy callbacks that test the arguments expected from a certain
+ # signal and only allow being called once
+
+ def cb1(self, *args):
+ if tuple(self.args1) == args:
+ self.called1 += 1
+ else:
+ raise ValueError('Invalid arguments for callback')
+
+ def cb2(self, *args):
+ if tuple(self.args2) == args:
+ self.called2 += 1
+ else:
+ raise ValueError('Invalid arguments for callback')
+
+ #
+ #######################################################################
+
+ def _check_cb12_once(self, *args):
+ self.assertEquals(self.called1, 1)
+ self.assertEquals(self.called2, 1)
+
+ @deferred()
+ def test_emits_correct(self):
+ self.tbt.test_signal1.connect(self.cb1)
+ self.tbt.test_signal2.connect(self.cb2)
+ d = self.tbt.run_checks_pass()
+
+ self.args1 = [{
+ AbstractBootstrapper.PASSED_KEY: True,
+ AbstractBootstrapper.ERROR_KEY: ""
+ }]
+
+ self.args2 = self.args1
+
+ d.addCallback(self._check_cb12_once)
+ return d
+
+ @deferred()
+ def test_emits_failed(self):
+ self.tbt.test_signal1.connect(self.cb1)
+ self.tbt.test_signal2.connect(self.cb2)
+ d = self.tbt.run_checks_fail()
+
+ self.args1 = [{
+ AbstractBootstrapper.PASSED_KEY: True,
+ AbstractBootstrapper.ERROR_KEY: ""
+ }]
+
+ self.args2 = [{
+ AbstractBootstrapper.PASSED_KEY: False,
+ AbstractBootstrapper.ERROR_KEY:
+ TesterBootstrapper.ERROR_MSG
+ }]
+
+ d.addCallback(self._check_cb12_once)
+ return d
+
+ @deferred()
+ def test_emits_failed_and_stops(self):
+ self.tbt.test_signal1.connect(self.cb1)
+ self.tbt.test_signal2.connect(self.cb2)
+ self.tbt.test_signal3.connect(self.cb1)
+ d = self.tbt.run_second_checks_fail()
+
+ self.args1 = [{
+ AbstractBootstrapper.PASSED_KEY: True,
+ AbstractBootstrapper.ERROR_KEY: ""
+ }]
+
+ self.args2 = [{
+ AbstractBootstrapper.PASSED_KEY: False,
+ AbstractBootstrapper.ERROR_KEY:
+ TesterBootstrapper.ERROR_MSG
+ }]
+
+ d.addCallback(self._check_cb12_once)
+ return d
+
+ @deferred()
+ def test_failed_without_signal(self):
+ d = self.tbt.run_third_checks_fail()
+ return d
+
+ @deferred()
+ def test_sucess_without_signal(self):
+ d = self.tbt.run_second_checks_pass()
+ return d
diff --git a/src/leap/bitmask/services/tx.py b/src/leap/bitmask/services/tx.py
new file mode 100644
index 00000000..adc6fcea
--- /dev/null
+++ b/src/leap/bitmask/services/tx.py
@@ -0,0 +1,46 @@
+# -*- coding: utf-8 -*-
+# twisted.py
+# Copyright (C) 2013 LEAP
+#
+# 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/>.
+"""
+Twisted services launched by the client
+"""
+import logging
+
+from twisted.application.service import Application
+#from twisted.internet.task import LoopingCall
+
+logger = logging.getLogger(__name__)
+
+
+def task():
+ """
+ stub periodic task, mainly for tests.
+ DELETE-ME when there's real meat here :)
+ """
+ from datetime import datetime
+ logger.debug("hi there %s", datetime.now())
+
+
+def leap_services():
+ """
+ Check which twisted services are enabled and
+ register them.
+ """
+ logger.debug('starting leap services')
+ application = Application("Bitmask Local Services")
+ #lc = LoopingCall(task)
+ #lc.start(5)
+ return application
diff --git a/src/leap/bitmask/util/__init__.py b/src/leap/bitmask/util/__init__.py
new file mode 100644
index 00000000..ce8323cd
--- /dev/null
+++ b/src/leap/bitmask/util/__init__.py
@@ -0,0 +1,105 @@
+# -*- coding: utf-8 -*-
+# __init__.py
+# Copyright (C) 2013 LEAP
+#
+# 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/>.
+"""
+Initializes version and app info, plus some small and handy functions.
+"""
+import datetime
+import os
+
+from pkg_resources import parse_version
+
+
+def _is_release_version(version):
+ """
+ Helper to determine whether a version is a final release or not.
+ The release needs to be of the form: w.x.y.z containing only numbers
+ and dots.
+
+ :param version: the version string
+ :type version: str
+ :returns: if the version is a release version or not.
+ :rtype: bool
+ """
+ parsed_version = parse_version(version)
+ not_number = 0
+ for x in parsed_version:
+ try:
+ int(x)
+ except:
+ not_number += 1
+
+ return not_number == 1
+
+
+__version__ = "unknown"
+IS_RELEASE_VERSION = False
+
+try:
+ from leap.bitmask._version import get_versions
+ __version__ = get_versions()['version']
+ IS_RELEASE_VERSION = _is_release_version(__version__)
+ del get_versions
+except ImportError:
+ #running on a tree that has not run
+ #the setup.py setver
+ pass
+
+__appname__ = "unknown"
+try:
+ from leap._appname import __appname__
+except ImportError:
+ #running on a tree that has not run
+ #the setup.py setver
+ pass
+
+__full_version__ = __appname__ + '/' + str(__version__)
+
+
+def first(things):
+ """
+ Return the head of a collection.
+ """
+ try:
+ return things[0]
+ except TypeError:
+ return None
+
+
+def get_modification_ts(path):
+ """
+ Gets modification time of a file.
+
+ :param path: the path to get ts from
+ :type path: str
+ :returns: modification time
+ :rtype: datetime object
+ """
+ ts = os.path.getmtime(path)
+ return datetime.datetime.fromtimestamp(ts)
+
+
+def update_modification_ts(path):
+ """
+ Sets modification time of a file to current time.
+
+ :param path: the path to set ts to.
+ :type path: str
+ :returns: modification time
+ :rtype: datetime object
+ """
+ os.utime(path, None)
+ return get_modification_ts(path)
diff --git a/src/leap/bitmask/util/constants.py b/src/leap/bitmask/util/constants.py
new file mode 100644
index 00000000..63f6b1f7
--- /dev/null
+++ b/src/leap/bitmask/util/constants.py
@@ -0,0 +1,19 @@
+# -*- coding: utf-8 -*-
+# constants.py
+# Copyright (C) 2013 LEAP
+#
+# 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/>.
+
+SIGNUP_TIMEOUT = 5
+REQUEST_TIMEOUT = 10
diff --git a/src/leap/bitmask/util/keyring_helpers.py b/src/leap/bitmask/util/keyring_helpers.py
new file mode 100644
index 00000000..8f354f28
--- /dev/null
+++ b/src/leap/bitmask/util/keyring_helpers.py
@@ -0,0 +1,37 @@
+# -*- coding: utf-8 -*-
+# keyring_helpers.py
+# Copyright (C) 2013 LEAP
+#
+# 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/>.
+
+"""
+Keyring helpers.
+"""
+
+import keyring
+
+OBSOLETE_KEYRINGS = [
+ keyring.backends.file.EncryptedKeyring,
+ keyring.backends.file.PlaintextKeyring
+]
+
+
+def has_keyring():
+ """
+ Returns whether we have an useful keyring to use.
+
+ :rtype: bool
+ """
+ kr = keyring.get_keyring()
+ return kr is not None and kr.__class__ not in OBSOLETE_KEYRINGS
diff --git a/src/leap/bitmask/util/leap_argparse.py b/src/leap/bitmask/util/leap_argparse.py
new file mode 100644
index 00000000..71f5163d
--- /dev/null
+++ b/src/leap/bitmask/util/leap_argparse.py
@@ -0,0 +1,72 @@
+# -*- coding: utf-8 -*-
+# leap_argparse.py
+# Copyright (C) 2013 LEAP
+#
+# 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/>.
+
+import argparse
+
+from leap.bitmask.util import IS_RELEASE_VERSION
+
+
+def build_parser():
+ """
+ All the options for the leap arg parser
+ Some of these could be switched on only if debug flag is present!
+ """
+ epilog = "Copyright 2012 The LEAP Encryption Access Project"
+ parser = argparse.ArgumentParser(description="""
+Launches Bitmask""", epilog=epilog)
+ parser.add_argument('-d', '--debug', action="store_true",
+ help=("Launches Bitmask in debug mode, writing debug"
+ "info to stdout"))
+ # TODO: when we are ready to disable the --danger flag remove 'True or '
+ if True or not IS_RELEASE_VERSION:
+ help_text = "Bypasses the certificate check for bootstrap"
+ parser.add_argument('--danger', action="store_true", help=help_text)
+
+ parser.add_argument('-l', '--logfile', metavar="LOG FILE", nargs='?',
+ action="store", dest="log_file",
+ #type=argparse.FileType('w'),
+ help='optional log file')
+ parser.add_argument('--openvpn-verbosity', nargs='?',
+ type=int,
+ action="store", dest="openvpn_verb",
+ help='verbosity level for openvpn logs [1-6]')
+ parser.add_argument('-s', '--standalone', action="store_true",
+ help='Makes Bitmask use standalone'
+ 'directories for configuration and binary'
+ 'searching')
+
+ # Not in use, we might want to reintroduce them.
+ #parser.add_argument('-i', '--no-provider-checks',
+ #action="store_true", default=False,
+ #help="skips download of provider config files. gets "
+ #"config from local files only. Will fail if cannot "
+ #"find any")
+ #parser.add_argument('-k', '--no-ca-verify',
+ #action="store_true", default=False,
+ #help="(insecure). Skips verification of the server "
+ #"certificate used in TLS handshake.")
+ #parser.add_argument('-c', '--config', metavar="CONFIG FILE", nargs='?',
+ #action="store", dest="config_file",
+ #type=argparse.FileType('r'),
+ #help='optional config file')
+ return parser
+
+
+def init_leapc_args():
+ parser = build_parser()
+ opts, unknown = parser.parse_known_args()
+ return parser, opts
diff --git a/src/leap/bitmask/util/leap_log_handler.py b/src/leap/bitmask/util/leap_log_handler.py
new file mode 100644
index 00000000..9adb21a5
--- /dev/null
+++ b/src/leap/bitmask/util/leap_log_handler.py
@@ -0,0 +1,134 @@
+# -*- coding: utf-8 -*-
+# leap_log_handler.py
+# Copyright (C) 2013 LEAP
+#
+# 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/>.
+"""
+Custom handler for the logger window.
+"""
+import logging
+
+from PySide import QtCore
+
+
+class LogHandler(logging.Handler):
+ """
+ This is the custom handler that implements our desired formatting
+ and also keeps a history of all the logged events.
+ """
+
+ MESSAGE_KEY = 'message'
+ RECORD_KEY = 'record'
+
+ def __init__(self, qtsignal):
+ """
+ LogHander initialization.
+ Calls parent method and keeps a reference to the qtsignal
+ that will be used to fire the gui update.
+ """
+ # TODO This is going to eat lots of memory after some time.
+ # Should be pruned at some moment.
+ self._log_history = []
+
+ logging.Handler.__init__(self)
+ self._qtsignal = qtsignal
+
+ def _get_format(self, logging_level):
+ """
+ Sets the log format depending on the parameter.
+ It uses html and css to set the colors for the logs.
+
+ :param logging_level: the debug level to define the color.
+ :type logging_level: str.
+ """
+ log_format = '%(asctime)s - %(name)s - %(levelname)s - %(message)s'
+ formatter = logging.Formatter(log_format)
+
+ return formatter
+
+ def emit(self, logRecord):
+ """
+ This method is fired every time that a record is logged by the
+ logging module.
+ This method reimplements logging.Handler.emit that is fired
+ in every logged message.
+
+ :param logRecord: the record emitted by the logging module.
+ :type logRecord: logging.LogRecord.
+ """
+ self.setFormatter(self._get_format(logRecord.levelname))
+ log = self.format(logRecord)
+ log_item = {self.RECORD_KEY: logRecord, self.MESSAGE_KEY: log}
+ self._log_history.append(log_item)
+ self._qtsignal(log_item)
+
+
+class HandlerAdapter(object):
+ """
+ New style class that accesses all attributes from the LogHandler.
+
+ Used as a workaround for a problem with multiple inheritance with Pyside
+ that surfaced under OSX with pyside 1.1.0.
+ """
+ MESSAGE_KEY = 'message'
+ RECORD_KEY = 'record'
+
+ def __init__(self, qtsignal):
+ self._handler = LogHandler(qtsignal=qtsignal)
+
+ def setLevel(self, *args, **kwargs):
+ return self._handler.setLevel(*args, **kwargs)
+
+ def handle(self, *args, **kwargs):
+ return self._handler.handle(*args, **kwargs)
+
+ @property
+ def level(self):
+ return self._handler.level
+
+
+class LeapLogHandler(QtCore.QObject, HandlerAdapter):
+ """
+ Custom logging handler. It emits Qt signals so it can be plugged to a gui.
+
+ Its inner handler also stores an history of logs that can be fetched after
+ having been connected to a gui.
+ """
+ # All dicts returned are of the form
+ # {'record': LogRecord, 'message': str}
+ new_log = QtCore.Signal(dict)
+
+ def __init__(self):
+ """
+ LeapLogHandler initialization.
+ Initializes parent classes.
+ """
+ QtCore.QObject.__init__(self)
+ HandlerAdapter.__init__(self, qtsignal=self.qtsignal)
+
+ def qtsignal(self, log_item):
+ # WARNING: the new-style connection does NOT work because PySide
+ # translates the emit method to self.emit, and that collides with
+ # the emit method for logging.Handler
+ # self.new_log.emit(log_item)
+ QtCore.QObject.emit(
+ self,
+ QtCore.SIGNAL('new_log(PyObject)'), log_item)
+
+ @property
+ def log_history(self):
+ """
+ Returns the history of the logged messages.
+ """
+ return self._handler._log_history
diff --git a/src/leap/bitmask/util/privilege_policies.py b/src/leap/bitmask/util/privilege_policies.py
new file mode 100644
index 00000000..72442553
--- /dev/null
+++ b/src/leap/bitmask/util/privilege_policies.py
@@ -0,0 +1,169 @@
+# -*- coding: utf-8 -*-
+# privilege_policies.py
+# Copyright (C) 2013 LEAP
+#
+# 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/>.
+"""
+Helpers to determine if the needed policies for privilege escalation
+are operative under this client run.
+"""
+import logging
+import os
+import platform
+
+from abc import ABCMeta, abstractmethod
+
+logger = logging.getLogger(__name__)
+
+
+POLICY_TEMPLATE = """<?xml version="1.0" encoding="UTF-8"?>
+<!DOCTYPE policyconfig PUBLIC
+ "-//freedesktop//DTD PolicyKit Policy Configuration 1.0//EN"
+ "http://www.freedesktop.org/standards/PolicyKit/1/policyconfig.dtd">
+<policyconfig>
+
+ <vendor>LEAP Project</vendor>
+ <vendor_url>https://leap.se/</vendor_url>
+
+ <action id="net.openvpn.gui.leap.run-openvpn">
+ <description>Runs the openvpn binary</description>
+ <description xml:lang="es">Ejecuta el binario openvpn</description>
+ <message>OpenVPN needs that you authenticate to start</message>
+ <message xml:lang="es">
+ OpenVPN necesita autorizacion para comenzar
+ </message>
+ <icon_name>package-x-generic</icon_name>
+ <defaults>
+ <allow_any>yes</allow_any>
+ <allow_inactive>yes</allow_inactive>
+ <allow_active>yes</allow_active>
+ </defaults>
+ <annotate key="org.freedesktop.policykit.exec.path">{path}</annotate>
+ <annotate key="org.freedesktop.policykit.exec.allow_gui">true</annotate>
+ </action>
+</policyconfig>
+"""
+
+
+def is_missing_policy_permissions():
+ """
+ Returns True if we do not have implemented a policy checker for this
+ platform, or if the policy checker exists but it cannot find the
+ appropriate policy mechanisms in place.
+
+ :rtype: bool
+ """
+ _system = platform.system()
+ platform_checker = _system + "PolicyChecker"
+ policy_checker = globals().get(platform_checker, None)
+ if not policy_checker:
+ # it is true that we miss permission to escalate
+ # privileges without asking for password each time.
+ logger.debug("we could not find a policy checker implementation "
+ "for %s" % (_system,))
+ return True
+ return policy_checker().is_missing_policy_permissions()
+
+
+def get_policy_contents(openvpn_path):
+ """
+ Returns the contents that the policy file should have.
+
+ :param openvpn_path: the openvpn path to use in the polkit file
+ :type openvpn_path: str
+ :rtype: str
+ """
+ return POLICY_TEMPLATE.format(path=openvpn_path)
+
+
+def is_policy_outdated(path):
+ """
+ Returns if the existing polkit file is outdated, comparing if the path
+ is correct.
+
+ :param path: the path that should have the polkit file.
+ :type path: str.
+ :rtype: bool
+ """
+ _system = platform.system()
+ platform_checker = _system + "PolicyChecker"
+ policy_checker = globals().get(platform_checker, None)
+ if policy_checker is None:
+ logger.debug("we could not find a policy checker implementation "
+ "for %s" % (_system,))
+ return False
+ return policy_checker().is_outdated(path)
+
+
+class PolicyChecker:
+ """
+ Abstract PolicyChecker class
+ """
+
+ __metaclass__ = ABCMeta
+
+ @abstractmethod
+ def is_missing_policy_permissions(self):
+ """
+ Returns True if we could not find any policy mechanisms that
+ are defined to be in used for this particular platform.
+
+ :rtype: bool
+ """
+ return True
+
+
+class LinuxPolicyChecker(PolicyChecker):
+ """
+ PolicyChecker for Linux
+ """
+ LINUX_POLKIT_FILE = ("/usr/share/polkit-1/actions/"
+ "net.openvpn.gui.leap.policy")
+
+ @classmethod
+ def get_polkit_path(self):
+ """
+ Returns the polkit file path.
+
+ :rtype: str
+ """
+ return self.LINUX_POLKIT_FILE
+
+ def is_missing_policy_permissions(self):
+ """
+ Returns True if we could not find the appropriate policykit file
+ in place
+
+ :rtype: bool
+ """
+ return not os.path.isfile(self.LINUX_POLKIT_FILE)
+
+ def is_outdated(self, path):
+ """
+ Returns if the existing polkit file is outdated, comparing if the path
+ is correct.
+
+ :param path: the path that should have the polkit file.
+ :type path: str.
+ :rtype: bool
+ """
+ polkit = None
+ try:
+ with open(self.LINUX_POLKIT_FILE) as f:
+ polkit = f.read()
+ except IOError, e:
+ logger.error("Error reading polkit file(%s): %r" % (
+ self.LINUX_POLKIT_FILE, e))
+
+ return get_policy_contents(path) != polkit
diff --git a/src/leap/bitmask/util/pyside_tests_helper.py b/src/leap/bitmask/util/pyside_tests_helper.py
new file mode 100644
index 00000000..5c0eb8d6
--- /dev/null
+++ b/src/leap/bitmask/util/pyside_tests_helper.py
@@ -0,0 +1,136 @@
+
+'''Helper classes and functions'''
+
+import os
+import unittest
+
+from random import randint
+
+from PySide.QtCore import QCoreApplication, QTimer
+
+try:
+ from PySide.QtGui import QApplication
+except ImportError:
+ has_gui = False
+else:
+ has_gui = True
+
+
+def adjust_filename(filename, orig_mod_filename):
+ dirpath = os.path.dirname(os.path.abspath(orig_mod_filename))
+ return os.path.join(dirpath, filename)
+
+
+class NoQtGuiError(Exception):
+ def __init__(self):
+ Exception.__init__(self, 'No QtGui found')
+
+
+class BasicPySlotCase(object):
+ '''Base class that tests python slots and signal emissions.
+
+ Python slots are defined as any callable passed to QObject.connect().
+ '''
+ def setUp(self):
+ self.called = False
+
+ def tearDown(self):
+ try:
+ del self.args
+ except:
+ pass
+
+ def cb(self, *args):
+ '''Simple callback with arbitrary arguments.
+
+ The test function must setup the 'args' attribute with a sequence
+ containing the arguments expected to be received by this slot.
+ Currently only a single connection is supported.
+ '''
+ if tuple(self.args) == args:
+ self.called = True
+ else:
+ raise ValueError('Invalid arguments for callback')
+
+
+_instance = None
+_timed_instance = None
+
+if has_gui:
+ class UsesQApplication(unittest.TestCase):
+ '''Helper class to provide QApplication instances'''
+
+ qapplication = True
+
+ def setUp(self):
+ '''Creates the QApplication instance'''
+
+ # Simple way of making instance a singleton
+ super(UsesQApplication, self).setUp()
+ global _instance
+ if _instance is None:
+ _instance = QApplication([])
+
+ self.app = _instance
+
+ def tearDown(self):
+ '''Deletes the reference owned by self'''
+ del self.app
+ super(UsesQApplication, self).tearDown()
+
+ class TimedQApplication(unittest.TestCase):
+ '''Helper class with timed QApplication exec loop'''
+
+ def setUp(self, timeout=100):
+ '''Setups this Application.
+
+ timeout - timeout in milisseconds'''
+ global _timed_instance
+ if _timed_instance is None:
+ _timed_instance = QApplication([])
+
+ self.app = _timed_instance
+ QTimer.singleShot(timeout, self.app.quit)
+
+ def tearDown(self):
+ '''Delete resources'''
+ del self.app
+else:
+ class UsesQApplication(unittest.TestCase):
+ def setUp(self):
+ raise NoQtGuiError()
+
+ class TimedQapplication(unittest.TestCase):
+ def setUp(self):
+ raise NoQtGuiError()
+
+_core_instance = None
+
+
+class UsesQCoreApplication(unittest.TestCase):
+ '''Helper class for test cases that require an QCoreApplication
+ Just connect or call self.exit_app_cb. When called, will ask
+ self.app to exit.
+ '''
+
+ def setUp(self):
+ '''Set up resources'''
+
+ global _core_instance
+ if _core_instance is None:
+ _core_instance = QCoreApplication([])
+
+ self.app = _core_instance
+
+ def tearDown(self):
+ '''Release resources'''
+ del self.app
+
+ def exit_app_cb(self):
+ '''Quits the application'''
+ self.app.exit(0)
+
+
+def random_string(size=5):
+ '''Generate random string with the given size'''
+ return ''.join(map(chr, [randint(33, 126) for x in range(size)]))
diff --git a/src/leap/bitmask/util/reqs.txt b/src/leap/bitmask/util/reqs.txt
new file mode 100644
index 00000000..0bcf85dc
--- /dev/null
+++ b/src/leap/bitmask/util/reqs.txt
@@ -0,0 +1,14 @@
+requests
+srp>=1.0.2
+pyopenssl
+keyring
+python-dateutil
+psutil
+ipaddr
+twisted
+qt4reactor
+python-gnupg
+leap.common>=0.2.5
+leap.soledad>=0.1.0
+mock
+oauth \ No newline at end of file
diff --git a/src/leap/bitmask/util/request_helpers.py b/src/leap/bitmask/util/request_helpers.py
new file mode 100644
index 00000000..60256b1e
--- /dev/null
+++ b/src/leap/bitmask/util/request_helpers.py
@@ -0,0 +1,56 @@
+# -*- coding: utf-8 -*-
+# request_helpers.py
+# Copyright (C) 2013 LEAP
+#
+# 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/>.
+
+"""
+Request helpers for backward compatible "parsing" of requests
+"""
+import time
+import json
+
+from dateutil import parser as dateparser
+
+
+def get_content(request):
+ """
+ Returns the content by trying to get it from the json
+ property/function or from content, in that order.
+ Also returns the mtime for that content if available
+
+ :param request: request as it is given by requests
+ :type request: Response
+
+ :rtype: tuple (contents, mtime)
+ """
+
+ contents = ""
+ mtime = None
+
+ if request and request.content and request.json:
+ if callable(request.json):
+ contents = json.dumps(request.json())
+ else:
+ contents = json.dumps(request.json)
+ else:
+ contents = request.content
+
+ mtime = None
+ last_modified = request.headers.get('last-modified', None)
+ if last_modified:
+ dt = dateparser.parse(unicode(last_modified))
+ mtime = int(time.mktime(dt.timetuple()) + dt.microsecond / 1000000.0)
+
+ return contents, mtime
diff --git a/src/leap/bitmask/util/requirement_checker.py b/src/leap/bitmask/util/requirement_checker.py
new file mode 100644
index 00000000..1d9b9923
--- /dev/null
+++ b/src/leap/bitmask/util/requirement_checker.py
@@ -0,0 +1,101 @@
+# -*- coding: utf-8 -*-
+# requirement_checker.py
+# Copyright (C) 2013 LEAP
+#
+# 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/>.
+
+"""
+Utility to check the needed requirements.
+"""
+
+import os
+import logging
+
+from pkg_resources import (DistributionNotFound,
+ get_distribution,
+ Requirement,
+ resource_stream,
+ VersionConflict)
+
+logger = logging.getLogger(__name__)
+
+
+def get_requirements():
+ """
+ This function returns a list with requirements.
+ It checks either if its running from the source or if its installed.
+
+ :returns: a list with packages names, required for the app.
+ :return type: list of str.
+ """
+ develop = True
+ requirements = []
+
+ try:
+ # if we are running from the source
+ from pkg import util
+ requirements = util.parse_requirements()
+ except ImportError:
+ develop = False
+
+ # if we are running from the package
+ if not develop:
+ requires_file_name = os.path.join('leap', 'util', 'reqs.txt')
+ dist_name = Requirement.parse('leap-client')
+
+ try:
+ with resource_stream(dist_name, requires_file_name) as stream:
+ requirements = [line.strip() for line in stream]
+ except Exception, e:
+ logger.error("Requirements file not found. %r" % (e, ))
+
+ return requirements
+
+
+def check_requirements():
+ """
+ This function check the dependencies declared in the
+ requirement(s) file(s) and logs the results.
+ """
+ logger.debug("Checking requirements...")
+ requirements = get_requirements()
+
+ for package in requirements:
+ try:
+ get_distribution(package)
+ except VersionConflict:
+ required_package = Requirement.parse(package)
+ required_version = required_package.specs[0]
+ required_name = required_package.key
+
+ installed_package = get_distribution(required_name)
+ installed_version = installed_package.version
+ installed_location = installed_package.location
+
+ msg = "Error: version not satisfied. "
+ msg += "Expected %s, installed %s (path: %s)." % (
+ required_version, installed_version, installed_location)
+
+ result = "%s ... %s" % (package, msg)
+ logger.error(result)
+ except DistributionNotFound:
+ msg = "Error: package not found!"
+ result = "%s ... %s" % (package, msg)
+ logger.error(result)
+ else:
+ msg = "OK"
+ result = "%s ... %s" % (package, msg)
+ logger.debug(result)
+
+ logger.debug('Done')
diff --git a/src/leap/bitmask/util/streamtologger.py b/src/leap/bitmask/util/streamtologger.py
new file mode 100644
index 00000000..25a06718
--- /dev/null
+++ b/src/leap/bitmask/util/streamtologger.py
@@ -0,0 +1,59 @@
+# -*- coding: utf-8 -*-
+# streamtologger.py
+# Copyright (C) 2013 LEAP
+#
+# 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/>.
+"""
+Stream object that redirects writes to a logger instance.
+"""
+import logging
+
+
+class StreamToLogger(object):
+ """
+ Fake file-like stream object that redirects writes to a logger instance.
+
+ Credits to:
+ http://www.electricmonk.nl/log/2011/08/14/\
+ redirect-stdout-and-stderr-to-a-logger-in-python/
+ """
+ def __init__(self, logger, log_level=logging.INFO):
+ """
+ Constructor, defines the logger and level to use to log messages.
+
+ :param logger: logger object to log messages.
+ :type logger: logging.Handler
+ :param log_level: the level to use to log messages through the logger.
+ :type log_level: int
+ look at logging-levels in 'logging' docs.
+ """
+ self._logger = logger
+ self._log_level = log_level
+
+ def write(self, data):
+ """
+ Simulates the 'write' method in a file object.
+ It writes the data receibed in buf to the logger 'self._logger'.
+
+ :param data: data to write to the 'file'
+ :type data: str
+ """
+ for line in data.rstrip().splitlines():
+ self._logger.log(self._log_level, line.rstrip())
+
+ def flush(self):
+ """
+ Dummy method. Needed to replace the twisted.log output.
+ """
+ pass
diff --git a/src/leap/bitmask/util/tests/__init__.py b/src/leap/bitmask/util/tests/__init__.py
new file mode 100644
index 00000000..e69de29b
--- /dev/null
+++ b/src/leap/bitmask/util/tests/__init__.py
diff --git a/src/leap/bitmask/util/tests/test_is_release_version.py b/src/leap/bitmask/util/tests/test_is_release_version.py
new file mode 100644
index 00000000..088ec66d
--- /dev/null
+++ b/src/leap/bitmask/util/tests/test_is_release_version.py
@@ -0,0 +1,57 @@
+# -*- coding: utf-8 -*-
+# test_is_release_version.py
+# Copyright (C) 2013 LEAP
+#
+# 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 _is_release_version function
+"""
+import unittest
+
+from leap.bitmask.util import _is_release_version as is_release_version
+from leap.common.testing.basetest import BaseLeapTest
+
+
+class TestIsReleaseVersion(BaseLeapTest):
+ """Tests for release version check."""
+
+ def setUp(self):
+ pass
+
+ def tearDown(self):
+ pass
+
+ def test_git_version(self):
+ version = '0.2.3-12-ge5b50a1'
+ self.assertFalse(is_release_version(version))
+
+ def test_release(self):
+ version = '0.2.4'
+ self.assertTrue(is_release_version(version))
+
+ def test_release_candidate(self):
+ version = '0.2.4-rc1'
+ self.assertFalse(is_release_version(version))
+
+ def test_complex_version(self):
+ version = '12.5.2.4-rc12.dev.alpha1'
+ self.assertFalse(is_release_version(version))
+
+ def test_super_high_version(self):
+ version = '12.5.2.4.45'
+ self.assertTrue(is_release_version(version))
+
+
+if __name__ == "__main__":
+ unittest.main(verbosity=2)
diff --git a/src/leap/bitmask/util/tests/test_leap_log_handler.py b/src/leap/bitmask/util/tests/test_leap_log_handler.py
new file mode 100644
index 00000000..518fd35b
--- /dev/null
+++ b/src/leap/bitmask/util/tests/test_leap_log_handler.py
@@ -0,0 +1,120 @@
+# -*- coding: utf-8 -*-
+# test_leap_log_handler.py
+# Copyright (C) 2013 LEAP
+#
+# 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_log_handler
+"""
+try:
+ import unittest2 as unittest
+except ImportError:
+ import unittest
+
+import logging
+
+from leap.bitmask.util.leap_log_handler import LeapLogHandler
+from leap.bitmask.util.pyside_tests_helper import BasicPySlotCase
+from leap.common.testing.basetest import BaseLeapTest
+
+from mock import Mock
+
+
+class LeapLogHandlerTest(BaseLeapTest, BasicPySlotCase):
+ """
+ LeapLogHandlerTest's tests.
+ """
+ def _callback(self, *args):
+ """
+ Simple callback to track if a signal was emitted.
+ """
+ self.called = True
+ self.emitted_msg = args[0][LeapLogHandler.MESSAGE_KEY]
+
+ def setUp(self):
+ BasicPySlotCase.setUp(self)
+
+ # Create the logger
+ level = logging.DEBUG
+ self.logger = logging.getLogger(name='test')
+ self.logger.setLevel(level)
+
+ # Create the handler
+ self.leap_handler = LeapLogHandler()
+ self.leap_handler.setLevel(level)
+ self.logger.addHandler(self.leap_handler)
+
+ def tearDown(self):
+ BasicPySlotCase.tearDown(self)
+ try:
+ self.leap_handler.new_log.disconnect()
+ except Exception:
+ pass
+
+ def test_history_starts_empty(self):
+ self.assertEqual(self.leap_handler.log_history, [])
+
+ def test_one_log_captured(self):
+ self.logger.debug('test')
+ self.assertEqual(len(self.leap_handler.log_history), 1)
+
+ def test_history_records_order(self):
+ self.logger.debug('test 01')
+ self.logger.debug('test 02')
+ self.logger.debug('test 03')
+
+ logs = []
+ for message in self.leap_handler.log_history:
+ logs.append(message[LeapLogHandler.RECORD_KEY].msg)
+
+ self.assertIn('test 01', logs)
+ self.assertIn('test 02', logs)
+ self.assertIn('test 03', logs)
+
+ def test_history_messages_order(self):
+ self.logger.debug('test 01')
+ self.logger.debug('test 02')
+ self.logger.debug('test 03')
+
+ logs = []
+ for message in self.leap_handler.log_history:
+ logs.append(message[LeapLogHandler.MESSAGE_KEY])
+
+ self.assertIn('test 01', logs[0])
+ self.assertIn('test 02', logs[1])
+ self.assertIn('test 03', logs[2])
+
+ def test_emits_signal(self):
+ log_format = '%(name)s - %(levelname)s - %(message)s'
+ formatter = logging.Formatter(log_format)
+ get_format = Mock(return_value=formatter)
+ self.leap_handler._handler._get_format = get_format
+
+ self.leap_handler.new_log.connect(self._callback)
+ self.logger.debug('test')
+
+ expected_log_msg = "test - DEBUG - test"
+
+ # signal emitted
+ self.assertTrue(self.called)
+
+ # emitted message
+ self.assertEqual(self.emitted_msg, expected_log_msg)
+
+ # Mock called
+ self.assertTrue(get_format.called)
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/src/leap/bitmask/util/tests/test_streamtologger.py b/src/leap/bitmask/util/tests/test_streamtologger.py
new file mode 100644
index 00000000..c4e55b3a
--- /dev/null
+++ b/src/leap/bitmask/util/tests/test_streamtologger.py
@@ -0,0 +1,122 @@
+# -*- coding: utf-8 -*-
+# test_streamtologger.py
+# Copyright (C) 2013 LEAP
+#
+# 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 streamtologger
+"""
+
+try:
+ import unittest2 as unittest
+except ImportError:
+ import unittest
+
+import logging
+import sys
+
+from leap.bitmask.util.streamtologger import StreamToLogger
+from leap.common.testing.basetest import BaseLeapTest
+
+
+class SimpleLogHandler(logging.Handler):
+ """
+ The simplest log handler that allows to check if the log was
+ delivered to the handler correctly.
+ """
+ def __init__(self):
+ logging.Handler.__init__(self)
+ self._last_log = ""
+ self._last_log_level = ""
+
+ def emit(self, record):
+ self._last_log = record.getMessage()
+ self._last_log_level = record.levelno
+
+ def get_last_log(self):
+ """
+ Returns the last logged message by this handler.
+
+ :return: the last logged message.
+ :rtype: str
+ """
+ return self._last_log
+
+ def get_last_log_level(self):
+ """
+ Returns the level of the last logged message by this handler.
+
+ :return: the last logged level.
+ :rtype: str
+ """
+ return self._last_log_level
+
+
+class StreamToLoggerTest(BaseLeapTest):
+ """
+ StreamToLogger's tests.
+
+ NOTE: we may need to find a way to test the use case that an exception
+ is raised. I couldn't catch the output of an exception because the
+ test failed if some exception is raised.
+ """
+ def setUp(self):
+ # Create the logger
+ level = logging.DEBUG
+ self.logger = logging.getLogger(name='test')
+ self.logger.setLevel(level)
+
+ # Simple log handler
+ self.handler = SimpleLogHandler()
+ self.logger.addHandler(self.handler)
+
+ # Preserve original values
+ self._sys_stdout = sys.stdout
+ self._sys_stderr = sys.stderr
+
+ # Create the handler
+ sys.stdout = StreamToLogger(self.logger, logging.DEBUG)
+ sys.stderr = StreamToLogger(self.logger, logging.ERROR)
+
+ def tearDown(self):
+ # Restore original values
+ sys.stdout = self._sys_stdout
+ sys.stderr = self._sys_stderr
+
+ def test_logger_starts_empty(self):
+ self.assertEqual(self.handler.get_last_log(), '')
+
+ def test_standard_output(self):
+ message = 'Test string'
+ print message
+
+ log = self.handler.get_last_log()
+ log_level = self.handler.get_last_log_level()
+
+ self.assertEqual(log, message)
+ self.assertEqual(log_level, logging.DEBUG)
+
+ def test_standard_error(self):
+ message = 'Test string'
+ sys.stderr.write(message)
+
+ log_level = self.handler.get_last_log_level()
+ log = self.handler.get_last_log()
+
+ self.assertEqual(log, message)
+ self.assertEqual(log_level, logging.ERROR)
+
+
+if __name__ == "__main__":
+ unittest.main(verbosity=2)