diff options
Diffstat (limited to 'src/leap/bitmask')
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><b>Provider:</b></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><b>Username:</b></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><b>Password:</b></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>&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 &out</string> + </property> + </action> + <action name="action_quit"> + <property name="text"> + <string>&Quit</string> + </property> + </action> + <action name="action_about_leap"> + <property name="text"> + <string>About &Bitmask</string> + </property> + </action> + <action name="action_help"> + <property name="text"> + <string>&Help</string> + </property> + </action> + <action name="action_wizard"> + <property name="text"> + <string>&Wizard</string> + </property> + </action> + <action name="action_show_logs"> + <property name="text"> + <string>Show &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><html><head/><body><p>Now we will guide you through some configuration that is needed before you can connect for the first time.</p><p>If you ever need to modify these options again, you can find the wizard in the <span style=" font-style:italic;">'Settings'</span> menu from the main window.</p><p>Do you want to <span style=" font-weight:600;">sign up</span> for a new account, or <span style=" font-weight:600;">log in</span> with an already existing username?</p></body></html></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><b>Services offered:</b></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><b>Enrollment policy:</b></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><b>URL:</b></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><b>Description:</b></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><b>Password:</b></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><b>Re-enter password:</b></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><b>Username:</b></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) |