From f4503842fdc288fb6336211099ad0a8d01165df3 Mon Sep 17 00:00:00 2001 From: Kali Kaneko Date: Fri, 4 Mar 2016 12:02:26 -0400 Subject: [feature] landing of bitmask.core --- .gitignore | 1 - src/leap/bitmask/core/__init__.py | 2 + src/leap/bitmask/core/_zmq.py | 68 ++++ src/leap/bitmask/core/bitmaskd.tac | 11 + src/leap/bitmask/core/configurable.py | 262 +++++++++++++ src/leap/bitmask/core/dispatcher.py | 196 ++++++++++ src/leap/bitmask/core/launcher.py | 37 ++ src/leap/bitmask/core/mail_services.py | 672 +++++++++++++++++++++++++++++++++ src/leap/bitmask/core/service.py | 174 +++++++++ src/leap/bitmask/core/uuid_map.py | 115 ++++++ src/leap/bitmask/core/web/__init__.py | 0 src/leap/bitmask/core/web/index.html | 70 ++++ src/leap/bitmask/core/web/root.py | 0 src/leap/bitmask/core/websocket.py | 98 +++++ 14 files changed, 1705 insertions(+), 1 deletion(-) create mode 100644 src/leap/bitmask/core/__init__.py create mode 100644 src/leap/bitmask/core/_zmq.py create mode 100644 src/leap/bitmask/core/bitmaskd.tac create mode 100644 src/leap/bitmask/core/configurable.py create mode 100644 src/leap/bitmask/core/dispatcher.py create mode 100644 src/leap/bitmask/core/launcher.py create mode 100644 src/leap/bitmask/core/mail_services.py create mode 100644 src/leap/bitmask/core/service.py create mode 100644 src/leap/bitmask/core/uuid_map.py create mode 100644 src/leap/bitmask/core/web/__init__.py create mode 100644 src/leap/bitmask/core/web/index.html create mode 100644 src/leap/bitmask/core/web/root.py create mode 100644 src/leap/bitmask/core/websocket.py diff --git a/.gitignore b/.gitignore index 35c5d4a9..eb7280a5 100644 --- a/.gitignore +++ b/.gitignore @@ -15,7 +15,6 @@ ui_*.py !.gitattributes bin/ build/ -core dist/ docs/_build docs/covhtml diff --git a/src/leap/bitmask/core/__init__.py b/src/leap/bitmask/core/__init__.py new file mode 100644 index 00000000..bda4b8d0 --- /dev/null +++ b/src/leap/bitmask/core/__init__.py @@ -0,0 +1,2 @@ +APPNAME = "bitmask.core" +ENDPOINT = "ipc:///tmp/%s.sock" % APPNAME diff --git a/src/leap/bitmask/core/_zmq.py b/src/leap/bitmask/core/_zmq.py new file mode 100644 index 00000000..a656fc65 --- /dev/null +++ b/src/leap/bitmask/core/_zmq.py @@ -0,0 +1,68 @@ +# -*- coding: utf-8 -*- +# _zmq.py +# Copyright (C) 2015 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 . +""" +ZMQ REQ-REP Dispatcher. +""" + +from twisted.application import service +from twisted.internet import reactor +from twisted.python import log + +from txzmq import ZmqEndpoint, ZmqEndpointType +from txzmq import ZmqFactory, ZmqREPConnection + +from leap.bitmask.core import ENDPOINT +from leap.bitmask.core.dispatcher import CommandDispatcher + + +class ZMQServerService(service.Service): + + def __init__(self, core): + self._core = core + + def startService(self): + zf = ZmqFactory() + e = ZmqEndpoint(ZmqEndpointType.bind, ENDPOINT) + + self._conn = _DispatcherREPConnection(zf, e, self._core) + reactor.callWhenRunning(self._conn.do_greet) + service.Service.startService(self) + + def stopService(self): + service.Service.stopService(self) + + +class _DispatcherREPConnection(ZmqREPConnection): + + def __init__(self, zf, e, core): + ZmqREPConnection.__init__(self, zf, e) + self.dispatcher = CommandDispatcher(core) + + def gotMessage(self, msgId, *parts): + + r = self.dispatcher.dispatch(parts) + r.addCallback(self.defer_reply, msgId) + + def defer_reply(self, response, msgId): + reactor.callLater(0, self.reply, msgId, str(response)) + + def log_err(self, failure, msgId): + log.err(failure) + self.defer_reply("ERROR: %r" % failure, msgId) + + def do_greet(self): + log.msg('starting ZMQ dispatcher') diff --git a/src/leap/bitmask/core/bitmaskd.tac b/src/leap/bitmask/core/bitmaskd.tac new file mode 100644 index 00000000..3c9b1d8b --- /dev/null +++ b/src/leap/bitmask/core/bitmaskd.tac @@ -0,0 +1,11 @@ +# Service composition for bitmask-core. +# Run as: twistd -n -y bitmaskd.tac +# +from twisted.application import service + +from leap.bitmask.core.service import BitmaskBackend + + +bb = BitmaskBackend() +application = service.Application("bitmaskd") +bb.setServiceParent(application) diff --git a/src/leap/bitmask/core/configurable.py b/src/leap/bitmask/core/configurable.py new file mode 100644 index 00000000..3b97916d --- /dev/null +++ b/src/leap/bitmask/core/configurable.py @@ -0,0 +1,262 @@ +# -*- coding: utf-8 -*- +# configurable.py +# Copyright (C) 2015, 2016 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 . +""" +Configurable Backend for Bitmask Service. +""" +import ConfigParser +import locale +import os +import re +import sys + +from twisted.application import service +from twisted.python import log + +from leap.common import files + + +class MissingConfigEntry(Exception): + """ + A required config entry was not found. + """ + + +class ConfigurableService(service.MultiService): + + config_file = u"bitmaskd.cfg" + service_names = ('mail', 'eip', 'zmq', 'web') + + def __init__(self, basedir='~/.config/leap'): + service.MultiService.__init__(self) + + path = os.path.abspath(os.path.expanduser(basedir)) + if not os.path.isdir(path): + files.mkdir_p(path) + self.basedir = path + + # creates self.config + self.read_config() + + def get_config(self, section, option, default=None, boolean=False): + try: + if boolean: + return self.config.getboolean(section, option) + + item = self.config.get(section, option) + return item + + except (ConfigParser.NoOptionError, ConfigParser.NoSectionError): + if default is None: + fn = os.path.join(self.basedir, self.config_file) + raise MissingConfigEntry("%s is missing the [%s]%s entry" + % (_quote_output(fn), + section, option)) + return default + + def set_config(self, section, option, value): + if not self.config.has_section(section): + self.config.add_section(section) + self.config.set(section, option, value) + self.save_config() + self.read_config() + assert self.config.get(section, option) == value + + def read_config(self): + self.config = ConfigParser.SafeConfigParser() + bitmaskd_cfg = self._get_config_path() + + if not os.path.isfile(bitmaskd_cfg): + self._create_default_config(bitmaskd_cfg) + + try: + with open(bitmaskd_cfg, "rb") as f: + self.config.readfp(f) + except EnvironmentError: + if os.path.exists(bitmaskd_cfg): + raise + + def save_config(self): + bitmaskd_cfg = self._get_config_path() + with open(bitmaskd_cfg, 'wb') as f: + self.config.write(f) + + def _create_default_config(self, path): + with open(path, 'w') as outf: + outf.write(DEFAULT_CONFIG) + + def _get_config_path(self): + return os.path.join(self.basedir, self.config_file) + + +DEFAULT_CONFIG = """ +[services] +mail = True +eip = True +zmq = True +web = False +""" + + +def canonical_encoding(encoding): + if encoding is None: + log.msg("Warning: falling back to UTF-8 encoding.", level=log.WEIRD) + encoding = 'utf-8' + encoding = encoding.lower() + if encoding == "cp65001": + encoding = 'utf-8' + elif (encoding == "us-ascii" or encoding == "646" or encoding == + "ansi_x3.4-1968"): + encoding = 'ascii' + + return encoding + + +def check_encoding(encoding): + # sometimes Python returns an encoding name that it doesn't support for + # conversion fail early if this happens + try: + u"test".encode(encoding) + except (LookupError, AttributeError): + raise AssertionError( + "The character encoding '%s' is not supported for conversion." % ( + encoding,)) + +filesystem_encoding = None +io_encoding = None +is_unicode_platform = False + + +def _reload(): + global filesystem_encoding, io_encoding, is_unicode_platform + + filesystem_encoding = canonical_encoding(sys.getfilesystemencoding()) + check_encoding(filesystem_encoding) + + if sys.platform == 'win32': + # On Windows we install UTF-8 stream wrappers for sys.stdout and + # sys.stderr, and reencode the arguments as UTF-8 (see + # scripts/runner.py). + io_encoding = 'utf-8' + else: + ioenc = None + if hasattr(sys.stdout, 'encoding'): + ioenc = sys.stdout.encoding + if ioenc is None: + try: + ioenc = locale.getpreferredencoding() + except Exception: + pass # work around + io_encoding = canonical_encoding(ioenc) + + check_encoding(io_encoding) + + is_unicode_platform = sys.platform in ["win32", "darwin"] + +_reload() + + +def _quote_output(s, quotemarks=True, quote_newlines=None, encoding=None): + """ + Encode either a Unicode string or a UTF-8-encoded bytestring for + representation on stdout or stderr, tolerating errors. If 'quotemarks' is + True, the string is always quoted; otherwise, it is quoted only if + necessary to avoid ambiguity or control bytes in the output. (Newlines are + counted as control bytes iff quote_newlines is True.) + + Quoting may use either single or double quotes. Within single quotes, all + characters stand for themselves, and ' will not appear. Within double + quotes, Python-compatible backslash escaping is used. + + If not explicitly given, quote_newlines is True when quotemarks is True. + """ + assert isinstance(s, (str, unicode)) + if quote_newlines is None: + quote_newlines = quotemarks + + if isinstance(s, str): + try: + s = s.decode('utf-8') + except UnicodeDecodeError: + return 'b"%s"' % ( + ESCAPABLE_8BIT.sub( + lambda m: _str_escape(m, quote_newlines), s),) + + must_double_quote = (quote_newlines and MUST_DOUBLE_QUOTE_NL or + MUST_DOUBLE_QUOTE) + if must_double_quote.search(s) is None: + try: + out = s.encode(encoding or io_encoding) + if quotemarks or out.startswith('"'): + return "'%s'" % (out,) + else: + return out + except (UnicodeDecodeError, UnicodeEncodeError): + pass + + escaped = ESCAPABLE_UNICODE.sub( + lambda m: _unicode_escape(m, quote_newlines), s) + return '"%s"' % ( + escaped.encode(encoding or io_encoding, 'backslashreplace'),) + + +def _unicode_escape(m, quote_newlines): + u = m.group(0) + if u == u'"' or u == u'$' or u == u'`' or u == u'\\': + return u'\\' + u + elif u == u'\n' and not quote_newlines: + return u + if len(u) == 2: + codepoint = ( + ord(u[0]) - 0xD800) * 0x400 + ord(u[1]) - 0xDC00 + 0x10000 + else: + codepoint = ord(u) + if codepoint > 0xFFFF: + return u'\\U%08x' % (codepoint,) + elif codepoint > 0xFF: + return u'\\u%04x' % (codepoint,) + else: + return u'\\x%02x' % (codepoint,) + + +def _str_escape(m, quote_newlines): + c = m.group(0) + if c == '"' or c == '$' or c == '`' or c == '\\': + return '\\' + c + elif c == '\n' and not quote_newlines: + return c + else: + return '\\x%02x' % (ord(c),) + +MUST_DOUBLE_QUOTE_NL = re.compile( + ur'[^\x20-\x26\x28-\x7E\u00A0-\uD7FF\uE000-\uFDCF\uFDF0-\uFFFC]', + re.DOTALL) +MUST_DOUBLE_QUOTE = re.compile( + ur'[^\n\x20-\x26\x28-\x7E\u00A0-\uD7FF\uE000-\uFDCF\uFDF0-\uFFFC]', + re.DOTALL) + +ESCAPABLE_8BIT = re.compile( + r'[^ !#\x25-\x5B\x5D-\x5F\x61-\x7E]', + re.DOTALL) + +# if we must double-quote, then we have to escape ", $ and `, but need not +# escape ' + +ESCAPABLE_UNICODE = re.compile( + ur'([\uD800-\uDBFF][\uDC00-\uDFFF])|' # valid surrogate pairs + ur'[^ !#\x25-\x5B\x5D-\x5F\x61-\x7E\u00A0-\uD7FF' + ur'\uE000-\uFDCF\uFDF0-\uFFFC]', + re.DOTALL) diff --git a/src/leap/bitmask/core/dispatcher.py b/src/leap/bitmask/core/dispatcher.py new file mode 100644 index 00000000..4d7e1813 --- /dev/null +++ b/src/leap/bitmask/core/dispatcher.py @@ -0,0 +1,196 @@ +# -*- coding: utf-8 -*- +# dispatcher.py +# Copyright (C) 2016 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 . +""" +Command dispatcher. +""" +import json + +from twisted.internet import defer +from twisted.python import failure, log + + +# TODO implement sub-classes to dispatch subcommands (user, mail). + + +class CommandDispatcher(object): + + def __init__(self, core): + + self.core = core + + def _get_service(self, name): + + try: + return self.core.getServiceNamed(name) + except KeyError: + return None + + def dispatch(self, msg): + cmd = msg[0] + + _method = getattr(self, 'do_' + cmd.upper(), None) + + if not _method: + return defer.fail(failure.Failure(RuntimeError('No such command'))) + + return defer.maybeDeferred(_method, *msg) + + def do_STATS(self, *parts): + return _format_result(self.core.do_stats()) + + def do_VERSION(self, *parts): + return _format_result(self.core.do_version()) + + def do_STATUS(self, *parts): + return _format_result(self.core.do_status()) + + def do_SHUTDOWN(self, *parts): + return _format_result(self.core.do_shutdown()) + + def do_USER(self, *parts): + + subcmd = parts[1] + user, password = parts[2], parts[3] + + bf = self._get_service('bonafide') + + if subcmd == 'authenticate': + d = bf.do_authenticate(user, password) + + elif subcmd == 'signup': + d = bf.do_signup(user, password) + + elif subcmd == 'logout': + d = bf.do_logout(user, password) + + elif subcmd == 'active': + d = bf.do_get_active_user() + + d.addCallbacks(_format_result, _format_error) + return d + + def do_EIP(self, *parts): + subcmd = parts[1] + eip_label = 'eip' + + if subcmd == 'enable': + return _format_result( + self.core.do_enable_service(eip_label)) + + eip = self._get_service(eip_label) + if not eip: + return _format_result('eip: disabled') + + if subcmd == 'status': + return _format_result(eip.do_status()) + + elif subcmd == 'disable': + return _format_result( + self.core.do_disable_service(eip_label)) + + elif subcmd == 'start': + # TODO --- attempt to get active provider + # TODO or catch the exception and send error + provider = parts[2] + d = eip.do_start(provider) + d.addCallbacks(_format_result, _format_error) + return d + + elif subcmd == 'stop': + d = eip.do_stop() + d.addCallbacks(_format_result, _format_error) + return d + + def do_MAIL(self, *parts): + + subcmd = parts[1] + mail_label = 'mail' + + if subcmd == 'enable': + return _format_result( + self.core.do_enable_service(mail_label)) + + m = self._get_service(mail_label) + bf = self._get_service('bonafide') + + if not m: + return _format_result('mail: disabled') + + if subcmd == 'status': + return _format_result(m.do_status()) + + elif subcmd == 'disable': + return _format_result(self.core.do_disable_service(mail_label)) + + elif subcmd == 'get_imap_token': + d = m.get_imap_token() + d.addCallbacks(_format_result, _format_error) + return d + + elif subcmd == 'get_smtp_token': + d = m.get_smtp_token() + d.addCallbacks(_format_result, _format_error) + return d + + elif subcmd == 'get_smtp_certificate': + # TODO move to mail service + # TODO should ask for confirmation? like --force or something, + # if we already have a valid one. or better just refuse if cert + # exists. + # TODO how should we pass the userid?? + # - Keep an 'active' user in bonafide (last authenticated) + # (doing it now) + # - Get active user from Mail Service (maybe preferred?) + # - Have a command/method to set 'active' user. + + @defer.inlineCallbacks + def save_cert(cert_data): + userid, cert_str = cert_data + cert_path = yield m.do_get_smtp_cert_path(userid) + with open(cert_path, 'w') as outf: + outf.write(cert_str) + defer.returnValue('certificate saved to %s' % cert_path) + + d = bf.do_get_smtp_cert() + d.addCallback(save_cert) + d.addCallbacks(_format_result, _format_error) + return d + + def do_KEYS(self, *parts): + subcmd = parts[1] + + keymanager_label = 'keymanager' + km = self._get_service(keymanager_label) + bf = self._get_service('bonafide') + + if not km: + return _format_result('keymanager: disabled') + + if subcmd == 'list_keys': + d = bf.do_get_active_user() + d.addCallback(km.do_list_keys) + d.addCallbacks(_format_result, _format_error) + return d + + +def _format_result(result): + return json.dumps({'error': None, 'result': result}) + + +def _format_error(failure): + log.err(failure) + return json.dumps({'error': failure.value.message, 'result': None}) diff --git a/src/leap/bitmask/core/launcher.py b/src/leap/bitmask/core/launcher.py new file mode 100644 index 00000000..7d658017 --- /dev/null +++ b/src/leap/bitmask/core/launcher.py @@ -0,0 +1,37 @@ +# -*- coding: utf-8 -*- +# launcher.py +# Copyright (C) 2016 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 . +""" +Run bitmask daemon. +""" +from twisted.scripts.twistd import run +from os.path import join, dirname +from sys import argv + +from leap.bitmask import core + + +def run_bitmaskd(): + # TODO --- configure where to put the logs... (get --logfile, --logdir + # from the bitmask_cli + argv[1:] = [ + '-y', join(dirname(core.__file__), "bitmaskd.tac"), + '--pidfile', '/tmp/bitmaskd.pid', + '--logfile', '/tmp/bitmaskd.log', + '--umask=0022', + ] + print '[+] launching bitmaskd...' + run() diff --git a/src/leap/bitmask/core/mail_services.py b/src/leap/bitmask/core/mail_services.py new file mode 100644 index 00000000..9858d498 --- /dev/null +++ b/src/leap/bitmask/core/mail_services.py @@ -0,0 +1,672 @@ +""" +Mail services. + +This is quite moving work still. +This should be moved to the different packages when it stabilizes. +""" +import json +import os +from glob import glob +from collections import defaultdict +from collections import namedtuple + +from twisted.application import service +from twisted.internet import defer +from twisted.python import log + +from leap.bonafide import config +from leap.common.service_hooks import HookableService +from leap.keymanager import KeyManager, openpgp +from leap.keymanager.errors import KeyNotFound +from leap.soledad.client.api import Soledad +from leap.mail.constants import INBOX_NAME +from leap.mail.mail import Account +from leap.mail.imap.service import imap +from leap.mail.incoming.service import IncomingMail, INCOMING_CHECK_PERIOD +from leap.mail import smtp + +from leap.bitmask.core.uuid_map import UserMap + + +class Container(object): + + def __init__(self): + self._instances = defaultdict(None) + + def get_instance(self, key): + return self._instances.get(key, None) + + +class ImproperlyConfigured(Exception): + pass + + +def get_all_soledad_uuids(): + return [os.path.split(p)[-1].split('.db')[0] for p in + glob(os.path.expanduser('~/.config/leap/soledad/*.db'))] + # FIXME do not hardcode basedir + + +class SoledadContainer(Container): + + def __init__(self, basedir='~/.config/leap'): + # FIXME do not hardcode basedir + self._basedir = os.path.expanduser(basedir) + self._usermap = UserMap() + super(SoledadContainer, self).__init__() + + def add_instance(self, userid, passphrase, uuid=None, token=None): + + if not uuid: + bootstrapped_uuid = self._usermap.lookup_uuid(userid, passphrase) + uuid = bootstrapped_uuid + if not uuid: + return + else: + self._usermap.add(userid, uuid, passphrase) + + user, provider = userid.split('@') + + soledad_path = os.path.join(self._basedir, 'soledad') + soledad_url = _get_soledad_uri(self._basedir, provider) + cert_path = _get_ca_cert_path(self._basedir, provider) + + soledad = self._create_soledad_instance( + uuid, passphrase, soledad_path, soledad_url, + cert_path, token) + + self._instances[userid] = soledad + + data = {'user': userid, 'uuid': uuid, 'token': token, + 'soledad': soledad} + self.service.trigger_hook('on_new_soledad_instance', **data) + + def _create_soledad_instance(self, uuid, passphrase, basedir, server_url, + cert_file, token): + # setup soledad info + secrets_path = os.path.join( + basedir, '%s.secret' % uuid) + local_db_path = os.path.join( + basedir, '%s.db' % uuid) + + if token is None: + syncable = False + token = '' + else: + syncable = True + + return Soledad( + uuid, + unicode(passphrase), + secrets_path=secrets_path, + local_db_path=local_db_path, + server_url=server_url, + cert_file=cert_file, + auth_token=token, + defer_encryption=True, + syncable=syncable) + + def set_remote_auth_token(self, userid, token): + self.get_instance(userid).token = token + + def set_syncable(self, userid, state): + # TODO should check that there's a token! + self.get_instance(userid).set_syncable(bool(state)) + + def sync(self, userid): + self.get_instance(userid).sync() + + +def _get_provider_from_full_userid(userid): + _, provider_id = config.get_username_and_provider(userid) + return config.Provider(provider_id) + + +def is_service_ready(service, provider): + """ + Returns True when the following conditions are met: + - Provider offers that service. + - We have the config files for the service. + - The service is enabled. + """ + has_service = provider.offers_service(service) + has_config = provider.has_config_for_service(service) + is_enabled = provider.is_service_enabled(service) + return has_service and has_config and is_enabled + + +class SoledadService(service.Service, HookableService): + + subscribed_to_hooks = ('on_bonafide_auth', 'on_passphrase_entry') + + def __init__(self, basedir): + service.Service.__init__(self) + self._basedir = basedir + + def startService(self): + log.msg('Starting Soledad Service') + self._container = SoledadContainer() + self._container.service = self + super(SoledadService, self).startService() + + # hooks + + def hook_on_passphrase_entry(self, **kw): + userid = kw.get('username') + provider = _get_provider_from_full_userid(userid) + provider.callWhenReady(self._hook_on_passphrase_entry, provider, **kw) + + def _hook_on_passphrase_entry(self, provider, **kw): + if is_service_ready('mx', provider): + userid = kw.get('username') + password = kw.get('password') + uuid = kw.get('uuid') + container = self._container + log.msg("on_passphrase_entry: New Soledad Instance: %s" % userid) + if not container.get_instance(userid): + container.add_instance(userid, password, uuid=uuid, token=None) + else: + log.msg('Service MX is not ready...') + + def hook_on_bonafide_auth(self, **kw): + userid = kw['username'] + provider = _get_provider_from_full_userid(userid) + provider.callWhenReady(self._hook_on_bonafide_auth, provider, **kw) + + def _hook_on_bonafide_auth(self, provider, **kw): + if provider.offers_service('mx'): + userid = kw['username'] + password = kw['password'] + token = kw['token'] + uuid = kw['uuid'] + + container = self._container + if container.get_instance(userid): + log.msg("Passing a new SRP Token to Soledad: %s" % userid) + container.set_remote_auth_token(userid, token) + container.set_syncable(userid, True) + else: + log.msg("Adding a new Soledad Instance: %s" % userid) + container.add_instance( + userid, password, uuid=uuid, token=token) + + +class KeymanagerContainer(Container): + + def __init__(self, basedir): + self._basedir = os.path.expanduser(basedir) + super(KeymanagerContainer, self).__init__() + + def add_instance(self, userid, token, uuid, soledad): + + keymanager = self._create_keymanager_instance( + userid, token, uuid, soledad) + + d = self._get_or_generate_keys(keymanager, userid) + d.addCallback(self._on_keymanager_ready_cb, userid, soledad) + return d + + def set_remote_auth_token(self, userid, token): + self.get_instance(userid)._token = token + + def _on_keymanager_ready_cb(self, keymanager, userid, soledad): + # TODO use onready-deferreds instead + self._instances[userid] = keymanager + + log.msg("Adding Keymanager instance for: %s" % userid) + data = {'userid': userid, 'soledad': soledad, 'keymanager': keymanager} + self.service.trigger_hook('on_new_keymanager_instance', **data) + + def _get_or_generate_keys(self, keymanager, userid): + + def if_not_found_generate(failure): + # TODO -------------- should ONLY generate if INITIAL_SYNC_DONE. + # ie: put callback on_soledad_first_sync_ready ----------------- + # -------------------------------------------------------------- + failure.trap(KeyNotFound) + log.msg("Core: Key not found. Generating key for %s" % (userid,)) + d = keymanager.gen_key(openpgp.OpenPGPKey) + d.addCallbacks(send_key, log_key_error("generating")) + return d + + def send_key(ignored): + # ---------------------------------------------------------------- + # It might be the case that we have generated a key-pair + # but this hasn't been successfully uploaded. How do we know that? + # XXX Should this be a method of bonafide instead? + # ----------------------------------------------------------------- + d = keymanager.send_key(openpgp.OpenPGPKey) + d.addCallbacks( + lambda _: log.msg( + "Key generated successfully for %s" % userid), + log_key_error("sending")) + return d + + def log_key_error(step): + def log_error(failure): + log.err("Error while %s key!" % step) + log.err(failure) + return failure + return log_error + + d = keymanager.get_key( + userid, openpgp.OpenPGPKey, private=True, fetch_remote=False) + d.addErrback(if_not_found_generate) + d.addCallback(lambda _: keymanager) + return d + + def _create_keymanager_instance(self, userid, token, uuid, soledad): + user, provider = userid.split('@') + nickserver_uri = self._get_nicknym_uri(provider) + + cert_path = _get_ca_cert_path(self._basedir, provider) + api_uri = self._get_api_uri(provider) + + if not token: + token = self.service.tokens.get(userid) + + km_args = (userid, nickserver_uri, soledad) + km_kwargs = { + "token": token, "uid": uuid, + "api_uri": api_uri, "api_version": "1", + "ca_cert_path": cert_path, + "gpgbinary": "/usr/bin/gpg" + } + keymanager = KeyManager(*km_args, **km_kwargs) + return keymanager + + def _get_api_uri(self, provider): + # TODO get this from service.json (use bonafide service) + api_uri = "https://api.{provider}:4430".format( + provider=provider) + return api_uri + + def _get_nicknym_uri(self, provider): + return 'https://nicknym.{provider}:6425'.format( + provider=provider) + + +class KeymanagerService(service.Service, HookableService): + + subscribed_to_hooks = ('on_new_soledad_instance', 'on_bonafide_auth') + + def __init__(self, basedir='~/.config/leap'): + service.Service.__init__(self) + self._basedir = basedir + + def startService(self): + log.msg('Starting Keymanager Service') + self._container = KeymanagerContainer(self._basedir) + self._container.service = self + self.tokens = {} + super(KeymanagerService, self).startService() + + # hooks + + def hook_on_new_soledad_instance(self, **kw): + container = self._container + user = kw['user'] + token = kw['token'] + uuid = kw['uuid'] + soledad = kw['soledad'] + if not container.get_instance(user): + log.msg('Adding a new Keymanager instance for %s' % user) + if not token: + token = self.tokens.get(user) + container.add_instance(user, token, uuid, soledad) + + def hook_on_bonafide_auth(self, **kw): + userid = kw['username'] + provider = _get_provider_from_full_userid(userid) + provider.callWhenReady(self._hook_on_bonafide_auth, provider, **kw) + + def _hook_on_bonafide_auth(self, provider, **kw): + if provider.offers_service('mx'): + userid = kw['username'] + token = kw['token'] + + container = self._container + if container.get_instance(userid): + log.msg('Passing a new SRP Token to Keymanager: %s' % userid) + container.set_remote_auth_token(userid, token) + else: + log.msg('storing the keymanager token...') + self.tokens[userid] = token + + # commands + + def do_list_keys(self, userid): + km = self._container.get_instance(userid) + d = km.get_all_keys() + d.addCallback( + lambda keys: [ + (key.uids, key.fingerprint) for key in keys]) + return d + + +class StandardMailService(service.MultiService, HookableService): + """ + A collection of Services. + + This is the parent service, that launches 3 different services that expose + Encrypted Mail Capabilities on specific ports: + + - SMTP service, on port 2013 + - IMAP service, on port 1984 + - The IncomingMail Service, which doesn't listen on any port, but + watches and processes the Incoming Queue and saves the processed mail + into the matching INBOX. + """ + + name = 'mail' + + # TODO factor out Mail Service to inside mail package. + + subscribed_to_hooks = ('on_new_keymanager_instance',) + + def __init__(self, basedir): + self._basedir = basedir + self._soledad_sessions = {} + self._keymanager_sessions = {} + self._sendmail_opts = {} + self._imap_tokens = {} + self._smtp_tokens = {} + self._active_user = None + super(StandardMailService, self).__init__() + self.initializeChildrenServices() + + def initializeChildrenServices(self): + self.addService(IMAPService(self._soledad_sessions)) + self.addService(IncomingMailService(self)) + self.addService(SMTPService( + self._soledad_sessions, self._keymanager_sessions, + self._sendmail_opts)) + + def startService(self): + log.msg('Starting Mail Service...') + super(StandardMailService, self).startService() + + def stopService(self): + super(StandardMailService, self).stopService() + + def startInstance(self, userid, soledad, keymanager): + username, provider = userid.split('@') + + self._soledad_sessions[userid] = soledad + self._keymanager_sessions[userid] = keymanager + + sendmail_opts = _get_sendmail_opts(self._basedir, provider, username) + self._sendmail_opts[userid] = sendmail_opts + + incoming = self.getServiceNamed('incoming_mail') + incoming.startInstance(userid) + + def registerIMAPToken(token): + self._imap_tokens[userid] = token + self._active_user = userid + return token + + def registerSMTPToken(token): + self._smtp_tokens[userid] = token + return token + + d = soledad.get_or_create_service_token('imap') + d.addCallback(registerIMAPToken) + d.addCallback( + lambda _: soledad.get_or_create_service_token('smtp')) + d.addCallback(registerSMTPToken) + return d + + def stopInstance(self): + pass + + # hooks + + def hook_on_new_keymanager_instance(self, **kw): + # XXX we can specify this as a waterfall, or just AND the two + # conditions. + userid = kw['userid'] + soledad = kw['soledad'] + keymanager = kw['keymanager'] + + # TODO --- only start instance if "autostart" is True. + self.startInstance(userid, soledad, keymanager) + + # commands + + def do_status(self): + return 'mail: %s' % 'running' if self.running else 'disabled' + + def get_imap_token(self): + active_user = self._active_user + if not active_user: + return defer.succeed('NO ACTIVE USER') + token = self._imap_tokens.get(active_user) + return defer.succeed("IMAP TOKEN (%s): %s" % (active_user, token)) + + def get_smtp_token(self): + active_user = self._active_user + if not active_user: + return defer.succeed('NO ACTIVE USER') + token = self._smtp_tokens.get(active_user) + return defer.succeed("SMTP TOKEN (%s): %s" % (active_user, token)) + + def do_get_smtp_cert_path(self, userid): + username, provider = userid.split('@') + return _get_smtp_client_cert_path(self._basedir, provider, username) + + # access to containers + + def get_soledad_session(self, userid): + return self._soledad_sessions.get(userid) + + def get_keymanager_session(self, userid): + return self._keymanager_sessions.get(userid) + + +class IMAPService(service.Service): + + name = 'imap' + + def __init__(self, soledad_sessions): + port, factory = imap.run_service(soledad_sessions) + + self._port = port + self._factory = factory + self._soledad_sessions = soledad_sessions + super(IMAPService, self).__init__() + + def startService(self): + log.msg('Starting IMAP Service') + super(IMAPService, self).startService() + + def stopService(self): + self._port.stopListening() + self._factory.doStop() + super(IMAPService, self).stopService() + + +class SMTPService(service.Service): + + name = 'smtp' + + def __init__(self, soledad_sessions, keymanager_sessions, sendmail_opts, + basedir='~/.config/leap'): + + self._basedir = os.path.expanduser(basedir) + port, factory = smtp.run_service( + soledad_sessions, keymanager_sessions, sendmail_opts) + self._port = port + self._factory = factory + self._soledad_sessions = soledad_sessions + self._keymanager_sessions = keymanager_sessions + self._sendmail_opts = sendmail_opts + super(SMTPService, self).__init__() + + def startService(self): + log.msg('Starting SMTP Service') + super(SMTPService, self).startService() + + def stopService(self): + # TODO cleanup all instances + super(SMTPService, self).stopService() + + +class IncomingMailService(service.Service): + + name = 'incoming_mail' + + def __init__(self, mail_service): + super(IncomingMailService, self).__init__() + self._mail = mail_service + self._instances = {} + + def startService(self): + log.msg('Starting IncomingMail Service') + super(IncomingMailService, self).startService() + + def stopService(self): + super(IncomingMailService, self).stopService() + + # Individual accounts + + # TODO IncomingMail *IS* already a service. + # I think we should better model the current Service + # as a startInstance inside a container, and get this + # multi-tenant service inside the leap.mail.incoming.service. + # ... or just simply make it a multiService and set per-user + # instances as Child of this parent. + + def startInstance(self, userid): + soledad = self._mail.get_soledad_session(userid) + keymanager = self._mail.get_keymanager_session(userid) + + log.msg('Starting Incoming Mail instance for %s' % userid) + self._start_incoming_mail_instance( + keymanager, soledad, userid) + + def stopInstance(self, userid): + # TODO toggle offline! + pass + + def _start_incoming_mail_instance(self, keymanager, soledad, + userid, start_sync=True): + + def setUpIncomingMail(inbox): + incoming_mail = IncomingMail( + keymanager, soledad, + inbox, userid, + check_period=INCOMING_CHECK_PERIOD) + return incoming_mail + + def registerInstance(incoming_instance): + self._instances[userid] = incoming_instance + if start_sync: + incoming_instance.startService() + + acc = Account(soledad) + d = acc.callWhenReady( + lambda _: acc.get_collection_by_mailbox(INBOX_NAME)) + d.addCallback(setUpIncomingMail) + d.addCallback(registerInstance) + d.addErrback(log.err) + return d + +# -------------------------------------------------------------------- +# +# config utilities. should be moved to bonafide +# + +SERVICES = ('soledad', 'smtp', 'eip') + + +Provider = namedtuple( + 'Provider', ['hostname', 'ip_address', 'location', 'port']) + +SendmailOpts = namedtuple( + 'SendmailOpts', ['cert', 'key', 'hostname', 'port']) + + +def _get_ca_cert_path(basedir, provider): + path = os.path.join( + basedir, 'providers', provider, 'keys', 'ca', 'cacert.pem') + return path + + +def _get_sendmail_opts(basedir, provider, username): + cert = _get_smtp_client_cert_path(basedir, provider, username) + key = cert + prov = _get_provider_for_service('smtp', basedir, provider) + hostname = prov.hostname + port = prov.port + opts = SendmailOpts(cert, key, hostname, port) + return opts + + +def _get_smtp_client_cert_path(basedir, provider, username): + path = os.path.join( + basedir, 'providers', provider, 'keys', 'client', 'stmp_%s.pem' % + username) + return path + + +def _get_config_for_service(service, basedir, provider): + if service not in SERVICES: + raise ImproperlyConfigured('Tried to use an unknown service') + + config_path = os.path.join( + basedir, 'providers', provider, '%s-service.json' % service) + try: + with open(config_path) as config: + config = json.loads(config.read()) + except IOError: + # FIXME might be that the provider DOES NOT offer this service! + raise ImproperlyConfigured( + 'could not open config file %s' % config_path) + else: + return config + + +def first(xs): + return xs[0] + + +def _pick_server(config, strategy=first): + """ + Picks a server from a list of possible choices. + The service files have a . + This implementation just picks the FIRST available server. + """ + servers = config['hosts'].keys() + choice = config['hosts'][strategy(servers)] + return choice + + +def _get_subdict(d, keys): + return {key: d.get(key) for key in keys} + + +def _get_provider_for_service(service, basedir, provider): + + if service not in SERVICES: + raise ImproperlyConfigured('Tried to use an unknown service') + + config = _get_config_for_service(service, basedir, provider) + p = _pick_server(config) + attrs = _get_subdict(p, ('hostname', 'ip_address', 'location', 'port')) + provider = Provider(**attrs) + return provider + + +def _get_smtp_uri(basedir, provider): + prov = _get_provider_for_service('smtp', basedir, provider) + url = 'https://{hostname}:{port}'.format( + hostname=prov.hostname, port=prov.port) + return url + + +def _get_soledad_uri(basedir, provider): + prov = _get_provider_for_service('soledad', basedir, provider) + url = 'https://{hostname}:{port}'.format( + hostname=prov.hostname, port=prov.port) + return url diff --git a/src/leap/bitmask/core/service.py b/src/leap/bitmask/core/service.py new file mode 100644 index 00000000..4c18ab5d --- /dev/null +++ b/src/leap/bitmask/core/service.py @@ -0,0 +1,174 @@ +# -*- coding: utf-8 -*- +# service.py +# Copyright (C) 2015 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 . +""" +Bitmask-core Service. +""" +import resource + +from twisted.internet import reactor +from twisted.python import log + +from leap.bonafide.service import BonafideService + +from leap.bitmask.core import configurable +from leap.bitmask.core import mail_services +from leap.bitmask.core import _zmq +from leap.bitmask.core import websocket +from leap.bitmask.core._version import get_versions + +from leap.common.events import server as event_server +from leap.vpn import EIPService + + +class BitmaskBackend(configurable.ConfigurableService): + + def __init__(self, basedir='~/.config/leap'): + + configurable.ConfigurableService.__init__(self, basedir) + + def enabled(service): + return self.get_config('services', service, False, boolean=True) + + on_start = reactor.callWhenRunning + + on_start(self.init_events) + on_start(self.init_bonafide) + + if enabled('mail'): + on_start(self.init_soledad) + on_start(self.init_keymanager) + on_start(self.init_mail) + + if enabled('eip'): + on_start(self.init_eip) + + if enabled('zmq'): + on_start(self.init_zmq) + + if enabled('web'): + on_start(self.init_web) + + def init_events(self): + event_server.ensure_server() + + def init_bonafide(self): + bf = BonafideService(self.basedir) + bf.setName("bonafide") + bf.setServiceParent(self) + # TODO ---- these hooks should be activated only if + # (1) we have enabled that service + # (2) provider offers this service + bf.register_hook('on_passphrase_entry', listener='soledad') + bf.register_hook('on_bonafide_auth', listener='soledad') + bf.register_hook('on_bonafide_auth', listener='keymanager') + + def init_soledad(self): + service = mail_services.SoledadService + sol = self._maybe_start_service( + 'soledad', service, self.basedir) + if sol: + sol.register_hook( + 'on_new_soledad_instance', listener='keymanager') + + def init_keymanager(self): + service = mail_services.KeymanagerService + km = self._maybe_start_service( + 'keymanager', service, self.basedir) + if km: + km.register_hook('on_new_keymanager_instance', listener='mail') + + def init_mail(self): + service = mail_services.StandardMailService + self._maybe_start_service('mail', service, self.basedir) + + def init_eip(self): + self._maybe_start_service('eip', EIPService) + + def init_zmq(self): + zs = _zmq.ZMQServerService(self) + zs.setServiceParent(self) + + def init_web(self): + ws = websocket.WebSocketsDispatcherService(self) + ws.setServiceParent(self) + + def _maybe_start_service(self, label, klass, *args, **kw): + try: + self.getServiceNamed(label) + except KeyError: + service = klass(*args, **kw) + service.setName(label) + service.setServiceParent(self) + return service + + # General commands for the BitmaskBackend Core Service + + def do_stats(self): + log.msg('BitmaskCore Service STATS') + mem = resource.getrusage(resource.RUSAGE_SELF).ru_maxrss + return 'BitmaskCore: [Mem usage: %s KB]' % (mem / 1024) + + def do_status(self): + # we may want to make this tuple a class member + services = ('soledad', 'keymanager', 'mail', 'eip') + + status_messages = [] + for name in services: + status = 'stopped' + try: + if self.getServiceNamed(name).running: + status = "running" + except KeyError: + pass + status_messages.append("[{}: {}]".format(name, status)) + + return " ".join(status_messages) + + def do_version(self): + version = get_versions()['version'] + return 'BitmaskCore: %s' % version + + def do_shutdown(self): + self.stopService() + reactor.callLater(1, reactor.stop) + return 'shutting down...' + + def do_enable_service(self, service): + assert service in self.service_names + self.set_config('services', service, 'True') + + if service == 'mail': + self.init_soledad() + self.init_keymanager() + self.init_mail() + + elif service == 'eip': + self.init_eip() + + elif service == 'zmq': + self.init_zmq() + + elif service == 'web': + self.init_web() + + return 'ok' + + def do_disable_service(self, service): + assert service in self.service_names + # TODO -- should stop also? + self.set_config('services', service, 'False') + return 'ok' diff --git a/src/leap/bitmask/core/uuid_map.py b/src/leap/bitmask/core/uuid_map.py new file mode 100644 index 00000000..5edc7216 --- /dev/null +++ b/src/leap/bitmask/core/uuid_map.py @@ -0,0 +1,115 @@ +# -*- coding: utf-8 -*- +# uuid_map.py +# Copyright (C) 2015,2016 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 . +""" +UUID Map: a persistent mapping between user-ids and uuids. +""" + +import base64 +import os +import re + +import scrypt + +from leap.common.config import get_path_prefix + + +MAP_PATH = os.path.join(get_path_prefix(), 'leap', 'uuids') + + +class UserMap(object): + + """ + A persistent mapping between user-ids and uuids. + """ + + # TODO Add padding to the encrypted string + + def __init__(self): + self._d = {} + self._lines = set([]) + if os.path.isfile(MAP_PATH): + self.load() + + def add(self, userid, uuid, passwd): + """ + Add a new userid-uuid mapping, and encrypt the record with the user + password. + """ + self._add_to_cache(userid, uuid) + self._lines.add(_encode_uuid_map(userid, uuid, passwd)) + self.dump() + + def _add_to_cache(self, userid, uuid): + self._d[userid] = uuid + + def load(self): + """ + Load a mapping from a default file. + """ + with open(MAP_PATH, 'r') as infile: + lines = infile.readlines() + self._lines = set(lines) + + def dump(self): + """ + Dump the mapping to a default file. + """ + with open(MAP_PATH, 'w') as out: + out.write('\n'.join(self._lines)) + + def lookup_uuid(self, userid, passwd=None): + """ + Lookup the uuid for a given userid. + + If no password is given, try to lookup on cache. + Else, try to decrypt all the records that we know about with the + passed password. + """ + if not passwd: + return self._d.get(userid) + + for line in self._lines: + guess = _decode_uuid_line(line, passwd) + if guess: + record_userid, uuid = guess + if record_userid == userid: + self._add_to_cache(userid, uuid) + return uuid + + def lookup_userid(self, uuid): + """ + Get the userid for the given uuid from cache. + """ + rev_d = {v: k for (k, v) in self._d.items()} + return rev_d.get(uuid) + + +def _encode_uuid_map(userid, uuid, passwd): + data = 'userid:%s:uuid:%s' % (userid, uuid) + encrypted = scrypt.encrypt(data, passwd, maxtime=0.05) + return base64.encodestring(encrypted).replace('\n', '') + + +def _decode_uuid_line(line, passwd): + decoded = base64.decodestring(line) + try: + maybe_decrypted = scrypt.decrypt(decoded, passwd, maxtime=0.1) + except scrypt.error: + return None + match = re.findall("userid\:(.+)\:uuid\:(.+)", maybe_decrypted) + if match: + return match[0] diff --git a/src/leap/bitmask/core/web/__init__.py b/src/leap/bitmask/core/web/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/leap/bitmask/core/web/index.html b/src/leap/bitmask/core/web/index.html new file mode 100644 index 00000000..9490eca8 --- /dev/null +++ b/src/leap/bitmask/core/web/index.html @@ -0,0 +1,70 @@ + + + + Bitmask WebSockets Endpoint + + + +

Bitmask Control Panel

+ +
+

Command:

+
+ +

+   
+
diff --git a/src/leap/bitmask/core/web/root.py b/src/leap/bitmask/core/web/root.py
new file mode 100644
index 00000000..e69de29b
diff --git a/src/leap/bitmask/core/websocket.py b/src/leap/bitmask/core/websocket.py
new file mode 100644
index 00000000..5569c6c7
--- /dev/null
+++ b/src/leap/bitmask/core/websocket.py
@@ -0,0 +1,98 @@
+# -*- coding: utf-8 -*-
+# websocket.py
+# Copyright (C) 2015, 2016 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 .
+
+"""
+WebSockets Dispatcher Service.
+"""
+
+import os
+import pkg_resources
+
+from twisted.internet import reactor
+from twisted.application import service
+
+from twisted.web.server import Site
+from twisted.web.static import File
+
+from autobahn.twisted.resource import WebSocketResource
+from autobahn.twisted.websocket import WebSocketServerFactory
+from autobahn.twisted.websocket import WebSocketServerProtocol
+
+from leap.bitmask.core.dispatcher import CommandDispatcher
+
+
+class WebSocketsDispatcherService(service.Service):
+
+    """
+    A Dispatcher for BitmaskCore exposing a WebSockets Endpoint.
+    """
+
+    def __init__(self, core, port=8080, debug=False):
+        self._core = core
+        self.port = port
+        self.debug = debug
+
+    def startService(self):
+
+        factory = WebSocketServerFactory(u"ws://127.0.0.1:%d" % self.port,
+                                         debug=self.debug)
+        factory.protocol = DispatcherProtocol
+        factory.protocol.dispatcher = CommandDispatcher(self._core)
+
+        # FIXME: Site.start/stopFactory should start/stop factories wrapped as
+        # Resources
+        factory.startFactory()
+
+        resource = WebSocketResource(factory)
+
+        # we server static files under "/" ..
+        webdir = os.path.abspath(
+            pkg_resources.resource_filename("leap.bitmask.core", "web"))
+        root = File(webdir)
+
+        # and our WebSocket server under "/ws"
+        root.putChild(u"bitmask", resource)
+
+        # both under one Twisted Web Site
+        site = Site(root)
+
+        self.site = site
+        self.factory = factory
+
+        self.listener = reactor.listenTCP(self.port, site)
+
+    def stopService(self):
+        self.factory.stopFactory()
+        self.site.stopFactory()
+        self.listener.stopListening()
+
+
+class DispatcherProtocol(WebSocketServerProtocol):
+
+    def onMessage(self, msg, binary):
+        parts = msg.split()
+        r = self.dispatcher.dispatch(parts)
+        r.addCallback(self.defer_reply, binary)
+
+    def reply(self, response, binary):
+        self.sendMessage(response, binary)
+
+    def defer_reply(self, response, binary):
+        reactor.callLater(0, self.reply, response, binary)
+
+    def _get_service(self, name):
+        return self.core.getServiceNamed(name)
-- 
cgit v1.2.3