diff options
23 files changed, 2395 insertions, 0 deletions
| diff --git a/src/leap/bitmask/cli/__init__.py b/src/leap/bitmask/cli/__init__.py new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/src/leap/bitmask/cli/__init__.py diff --git a/src/leap/bitmask/cli/bitmask_cli.py b/src/leap/bitmask/cli/bitmask_cli.py new file mode 100755 index 0000000..1f104c9 --- /dev/null +++ b/src/leap/bitmask/cli/bitmask_cli.py @@ -0,0 +1,119 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +# bitmask_cli +# 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 <http://www.gnu.org/licenses/>. +""" +Bitmask Command Line interface: zmq client. +""" +import json +import sys + +from colorama import Fore +from twisted.internet import reactor, defer + +from leap.bitmask.cli.eip import Eip +from leap.bitmask.cli.keys import Keys +from leap.bitmask.cli.mail import Mail +from leap.bitmask.cli import command +from leap.bitmask.cli.user import User + + +class BitmaskCLI(command.Command): +    usage = '''bitmaskctl <command> [<args>] + +Controls the Bitmask application. + +SERVICE COMMANDS: + +  user       Handles Bitmask accounts +  mail       Bitmask Encrypted Mail +  eip        Encrypted Internet Proxy +  keys       Bitmask Keymanager + +GENERAL COMMANDS: + +  version    prints version number and exit +  launch     launch the Bitmask backend daemon +  shutdown   shutdown Bitmask backend daemon +  status     displays general status about the running Bitmask services +  stats      show some debug info about bitmask-core +  help       show this help message + +''' +    epilog = ("Use 'bitmaskctl <command> help' to learn more " +              "about each command.") +    commands = ['shutdown', 'stats'] + +    def user(self, raw_args): +        user = User() +        return user.execute(raw_args) + +    def mail(self, raw_args): +        mail = Mail() +        return mail.execute(raw_args) + +    def eip(self, raw_args): +        eip = Eip() +        return eip.execute(raw_args) + +    def keys(self, raw_args): +        keys = Keys() +        return keys.execute(raw_args) + +    # Single commands + +    def launch(self, raw_args): +        # XXX careful! Should see if the process in PID is running, +        # avoid launching again. +        import commands +        commands.getoutput('bitmaskd') +        return defer.succeed(None) + +    def version(self, raw_args): +        print(Fore.GREEN + 'bitmaskctl:  ' + Fore.RESET + '0.0.1') +        self.data = ['version'] +        return self._send(printer=self._print_version) + +    def _print_version(self, version): +        corever = version['version_core'] +        print(Fore.GREEN + 'bitmask_core: ' + Fore.RESET + corever) + +    def status(self, raw_args): +        self.data = ['status'] +        return self._send(printer=self._print_status) + +    def _print_status(self, status): +        statusdict = json.loads(status) +        for key, value in statusdict.items(): +            color = Fore.GREEN +            if value == 'stopped': +                color = Fore.RED +            print(key.ljust(10) + ': ' + color + +                  value + Fore.RESET) + + +def execute(): +    cli = BitmaskCLI() +    d = cli.execute(sys.argv[1:]) +    d.addCallback(lambda _: reactor.stop()) + + +def main(): +    reactor.callWhenRunning(reactor.callLater, 0, execute) +    reactor.run() + +if __name__ == "__main__": +    main() diff --git a/src/leap/bitmask/cli/command.py b/src/leap/bitmask/cli/command.py new file mode 100644 index 0000000..36552c0 --- /dev/null +++ b/src/leap/bitmask/cli/command.py @@ -0,0 +1,120 @@ +# -*- coding: utf-8 -*- +# sender +# 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 <http://www.gnu.org/licenses/>. +""" +Bitmask Command Line interface: zmq sender. +""" +import argparse +import json +import sys + +from colorama import init as color_init +from colorama import Fore +from twisted.internet import defer +from txzmq import ZmqEndpoint, ZmqEndpointType +from txzmq import ZmqFactory, ZmqREQConnection +from txzmq import ZmqRequestTimeoutError + +from leap.bitmask.core import ENDPOINT + + +appname = 'bitmaskctl' + + +def _print_result(result): +    print Fore.GREEN + '%s' % result + Fore.RESET + + +def default_dict_printer(result): +    for key, value in result.items(): +        if value is None: +            value = str(value) +        print(Fore.RESET + key.ljust(10) + Fore.GREEN + value + Fore.RESET) + + +class Command(object): +    """A generic command dispatcher. +    Any command in the class attribute `commands` will be dispached and +    represented with a generic printer.""" +    service = '' +    usage = '''{name} <subcommand>'''.format(name=appname) +    epilog = ("Use bitmaskctl <subcommand> --help' to learn more " +              "about each command.") +    commands = [] + +    def __init__(self): +        color_init() +        zf = ZmqFactory() +        e = ZmqEndpoint(ZmqEndpointType.connect, ENDPOINT) +        self._conn = ZmqREQConnection(zf, e) + +        self.data = [] +        if self.service: +            self.data = [self.service] + +    def execute(self, raw_args): +        self.parser = argparse.ArgumentParser(usage=self.usage, +                                              epilog=self.epilog) +        self.parser.add_argument('command', help='Subcommand to run') +        try: +            args = self.parser.parse_args(raw_args[0:1]) +        except SystemExit: +            return defer.succeed(None) + +        # if command is in the default list, send the bare command +        # and use the default printer +        if args.command in self.commands: +            self.data += [args.command] +            return self._send(printer=default_dict_printer) + +        elif (args.command == 'execute' or +                args.command.startswith('_') or +                not hasattr(self, args.command)): +            print 'Unrecognized command' +            return self.help([]) + +        try: +            # use dispatch pattern to invoke method with same name +            return getattr(self, args.command)(raw_args[1:]) +        except SystemExit: +            return defer.succeed(None) + +    def help(self, raw_args): +        self.parser.print_help() +        return defer.succeed(None) + +    def _send(self, printer=_print_result): +        d = self._conn.sendMsg(*self.data, timeout=60) +        d.addCallback(self._check_err, printer) +        d.addErrback(self._timeout_handler) +        return d + +    def _error(self, msg): +        print Fore.RED + "[!] %s" % msg + Fore.RESET +        sys.exit(1) + +    def _check_err(self, stuff, printer): +        obj = json.loads(stuff[0]) +        if not obj['error']: +            return printer(obj['result']) +        else: +            print Fore.RED + 'ERROR:' + '%s' % obj['error'] + Fore.RESET + +    def _timeout_handler(self, failure): +        # TODO ---- could try to launch the bitmask daemon here and retry +        if failure.trap(ZmqRequestTimeoutError) == ZmqRequestTimeoutError: +            print (Fore.RED + "[ERROR] Timeout contacting the bitmask daemon. " +                   "Is it running?" + Fore.RESET) diff --git a/src/leap/bitmask/cli/eip.py b/src/leap/bitmask/cli/eip.py new file mode 100644 index 0000000..eac8682 --- /dev/null +++ b/src/leap/bitmask/cli/eip.py @@ -0,0 +1,37 @@ +# -*- coding: utf-8 -*- +# eip +# 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 <http://www.gnu.org/licenses/>. +""" +Bitmask Command Line interface: eip +""" +from leap.bitmask.cli import command + + +class Eip(command.Command): +    service = 'eip' +    usage = '''{name} eip <subcommand> + +Bitmask Encrypted Internet Service + +SUBCOMMANDS: + +   start      Start service +   stop       Stop service +   status     Display status about service + +'''.format(name=command.appname) + +    commands = ['start', 'stop', 'status'] diff --git a/src/leap/bitmask/cli/keys.py b/src/leap/bitmask/cli/keys.py new file mode 100644 index 0000000..d74c40a --- /dev/null +++ b/src/leap/bitmask/cli/keys.py @@ -0,0 +1,124 @@ +# -*- coding: utf-8 -*- +# keys +# 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 <http://www.gnu.org/licenses/>. +""" +Bitmask Command Line interface: keys +""" +import argparse +import sys + +from colorama import Fore + +from leap.bitmask.cli import command +from leap.keymanager.validation import ValidationLevels + + +class Keys(command.Command): +    service = 'keys' +    usage = '''{name} keys <subcommand> + +Bitmask Keymanager management service + +SUBCOMMANDS: + +   list       List all known keys +   export     Export a given key +   insert     Insert a key to the key storage +   delete     Delete a key from the key storage +'''.format(name=command.appname) + +    def list(self, raw_args): +        parser = argparse.ArgumentParser( +            description='Bitmask list keys', +            prog='%s %s %s' % tuple(sys.argv[:3])) +        parser.add_argument('--private', action='store_true', +                            help='Use private keys (by default uses public)') +        subargs = parser.parse_args(raw_args) + +        self.data += ['list'] +        if subargs.private: +            self.data += ['private'] +        else: +            self.data += ['public'] + +        return self._send(self._print_key_list) + +    def export(self, raw_args): +        parser = argparse.ArgumentParser( +            description='Bitmask export key', +            prog='%s %s %s' % tuple(sys.argv[:3])) +        parser.add_argument('--private', action='store_true', +                            help='Use private keys (by default uses public)') +        parser.add_argument('address', nargs=1, +                            help='email address of the key') +        subargs = parser.parse_args(raw_args) +        self.data += ['export', subargs.address[0]] + +        return self._send(self._print_key) + +    def insert(self, raw_args): +        parser = argparse.ArgumentParser( +            description='Bitmask import key', +            prog='%s %s %s' % tuple(sys.argv[:3])) +        parser.add_argument('--validation', choices=list(ValidationLevels), +                            default='Fingerprint', +                            help='Validation level for the key') +        parser.add_argument('file', nargs=1, +                            help='file where the key is stored') +        parser.add_argument('address', nargs=1, +                            help='email address of the key') +        subargs = parser.parse_args(raw_args) + +        with open(subargs.file[0], 'r') as keyfile: +            rawkey = keyfile.read() +        self.data += ['insert', subargs.address[0], subargs.validation, +                      rawkey] + +        return self._send(self._print_key) + +    def delete(self, raw_args): +        parser = argparse.ArgumentParser( +            description='Bitmask delete key', +            prog='%s %s %s' % tuple(sys.argv[:3])) +        parser.add_argument('--private', action='store_true', +                            help='Use private keys (by default uses public)') +        parser.add_argument('address', nargs=1, +                            help='email address of the key') +        subargs = parser.parse_args(raw_args) +        self.data += ['delete', subargs.address[0]] + +        return self._send() + +    def _print_key_list(self, keys): +        for key in keys: +            print(Fore.GREEN + +                  key["fingerprint"] + " " + key['address'] + +                  Fore.RESET) + +    def _print_key(self, key): +        print(Fore.GREEN) +        print("Uids:       " + ', '.join(key['uids'])) +        print("Fingerprint:" + key['fingerprint']) +        print("Length:     " + str(key['length'])) +        print("Expiration: " + key['expiry_date']) +        print("Validation: " + key['validation']) +        print("Used:       " + "sig:" + +              str(key['sign_used']) + ", encr:" + +              str(key['encr_used'])) +        print("Refreshed:   " + key['refreshed_at']) +        print(Fore.RESET) +        print("") +        print(key['key_data']) diff --git a/src/leap/bitmask/cli/mail.py b/src/leap/bitmask/cli/mail.py new file mode 100644 index 0000000..f0fa972 --- /dev/null +++ b/src/leap/bitmask/cli/mail.py @@ -0,0 +1,40 @@ +# -*- coding: utf-8 -*- +# mail +# 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 <http://www.gnu.org/licenses/>. +""" +Bitmask Command Line interface: mail +""" +from leap.bitmask.cli import command + + +class Mail(command.Command): +    service = 'mail' +    usage = '''{name} mail <subcommand> + +Bitmask Encrypted Email Service + +SUBCOMMANDS: + +   enable               Start service +   disable              Stop service +   status               Display status about service +   get_token            Returns token for the mail service +   get_smtp_certificate Downloads a new smtp certificate + +'''.format(name=command.appname) + +    commands = ['enable', 'disable', 'status', 'get_token', +                'get_smtp_certificate'] diff --git a/src/leap/bitmask/cli/user.py b/src/leap/bitmask/cli/user.py new file mode 100644 index 0000000..dccfc7d --- /dev/null +++ b/src/leap/bitmask/cli/user.py @@ -0,0 +1,76 @@ +# -*- coding: utf-8 -*- +# user +# 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 <http://www.gnu.org/licenses/>. +""" +Bitmask Command Line interface: user +""" +import argparse +import getpass +import sys + +from leap.bitmask.cli import command + + +class User(command.Command): +    service = 'user' +    usage = '''{name} user <subcommand> + +Bitmask account service + +SUBCOMMANDS: + +   create     Registers new user, if possible +   auth       Logs in against the provider +   logout     Ends any active session with the provider +   active     Shows the active user, if any + +'''.format(name=command.appname) + +    commands = ['active'] + +    def create(self, raw_args): +        username = self.username(raw_args) +        passwd = getpass.getpass() +        self.data += ['signup', username, passwd] +        return self._send(printer=command.default_dict_printer) + +    def auth(self, raw_args): +        username = self.username(raw_args) +        passwd = getpass.getpass() +        self.data += ['authenticate', username, passwd] +        return self._send(printer=command.default_dict_printer) + +    def logout(self, raw_args): +        username = self.username(raw_args) +        self.data += ['logout', username] +        return self._send(printer=command.default_dict_printer) + +    def username(self, raw_args): +        args = tuple([command.appname] + sys.argv[1:3]) +        parser = argparse.ArgumentParser( +            description='Bitmask user', +            prog='%s %s %s' % args) +        parser.add_argument('username', nargs=1, +                            help='username ID, in the form <user@example.org>') +        subargs = parser.parse_args(raw_args) + +        username = subargs.username[0] +        if not username: +            self._error("Missing username ID but needed for this command") +        if '@' not in username: +            self._error("Username ID must be in the form <user@example.org>") + +        return username diff --git a/src/leap/bitmask/core/__init__.py b/src/leap/bitmask/core/__init__.py new file mode 100644 index 0000000..bda4b8d --- /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 0000000..a656fc6 --- /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 <http://www.gnu.org/licenses/>. +""" +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/api.py b/src/leap/bitmask/core/api.py new file mode 100644 index 0000000..9f3725d --- /dev/null +++ b/src/leap/bitmask/core/api.py @@ -0,0 +1,54 @@ +# -*- coding: utf-8 -*- +# api.py +# Copyright (C) 2016 LEAP Encryption Acess Project +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program.  If not, see <http://www.gnu.org/licenses/>. +""" +Registry for the public API for the Bitmask Backend. +""" +from collections import OrderedDict + +registry = OrderedDict() + + +class APICommand(type): +    """ +    A metaclass to keep a global registry of all the methods that compose the +    public API for the Bitmask Backend. +    """ +    def __init__(cls, name, bases, attrs): +        for key, val in attrs.iteritems(): +            properties = getattr(val, 'register', None) +            label = getattr(cls, 'label', None) +            if label: +                name = label +            if properties is not None: +                registry['%s.%s' % (name, key)] = properties + + +def register_method(*args): +    """ +    This method gathers info about all the methods that are supposed to +    compose the public API to communicate with the backend. + +    It sets up a register property for any method that uses it. +    A type annotation is supposed to be in this property. +    The APICommand metaclass collects these properties of the methods and +    stores them in the global api_registry object, where they can be +    introspected at runtime. +    """ +    def decorator(f): +        f.register = tuple(args) +        return f +    return decorator diff --git a/src/leap/bitmask/core/bitmaskd.tac b/src/leap/bitmask/core/bitmaskd.tac new file mode 100644 index 0000000..3c9b1d8 --- /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 0000000..8e33de9 --- /dev/null +++ b/src/leap/bitmask/core/configurable.py @@ -0,0 +1,106 @@ +# -*- 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 <http://www.gnu.org/licenses/>. +""" +Configurable Backend for Bitmask Service. +""" +import ConfigParser +import os + +from twisted.application import service + +from leap.common import files +from leap.common.config import get_path_prefix + + +DEFAULT_BASEDIR = os.path.join(get_path_prefix(), 'leap') + + +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=DEFAULT_BASEDIR): +        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 = self._get_config_path() +                raise MissingConfigEntry("%s is missing the [%s]%s entry" +                                         % 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) + +        with open(bitmaskd_cfg, "rb") as f: +            self.config.readfp(f) + +    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 +""" diff --git a/src/leap/bitmask/core/dispatcher.py b/src/leap/bitmask/core/dispatcher.py new file mode 100644 index 0000000..e81cad6 --- /dev/null +++ b/src/leap/bitmask/core/dispatcher.py @@ -0,0 +1,328 @@ +# -*- 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 <http://www.gnu.org/licenses/>. +""" +Command dispatcher. +""" +import json + +from twisted.internet import defer +from twisted.python import failure, log + +from .api import APICommand, register_method + + +class SubCommand(object): + +    __metaclass__ = APICommand + +    def dispatch(self, service, *parts, **kw): +        subcmd = parts[1] + +        _method = getattr(self, 'do_' + subcmd.upper(), None) +        if not _method: +            raise RuntimeError('No such subcommand') +        return defer.maybeDeferred(_method, service, *parts, **kw) + + +class UserCmd(SubCommand): + +    label = 'user' + +    @register_method("{'srp_token': unicode, 'uuid': unicode}") +    def do_AUTHENTICATE(self, bonafide, *parts): +        user, password = parts[2], parts[3] +        d = defer.maybeDeferred(bonafide.do_authenticate, user, password) +        return d + +    @register_method("{'signup': 'ok', 'user': str}") +    def do_SIGNUP(self, bonafide, *parts): +        user, password = parts[2], parts[3] +        d = defer.maybeDeferred(bonafide.do_signup, user, password) +        return d + +    @register_method("{'logout': 'ok'}") +    def do_LOGOUT(self, bonafide, *parts): +        user = parts[2] +        d = defer.maybeDeferred(bonafide.do_logout, user) +        return d + +    @register_method('str') +    def do_ACTIVE(self, bonafide, *parts): +        d = defer.maybeDeferred(bonafide.do_get_active_user) +        return d + + +class EIPCmd(SubCommand): + +    label = 'eip' + +    @register_method('dict') +    def do_ENABLE(self, service, *parts): +        d = service.do_enable_service(self.label) +        return d + +    @register_method('dict') +    def do_DISABLE(self, service, *parts): +        d = service.do_disable_service(self.label) +        return d + +    @register_method('dict') +    def do_STATUS(self, eip, *parts): +        d = eip.do_status() +        return d + +    @register_method('dict') +    def do_START(self, eip, *parts): +        # TODO --- attempt to get active provider +        # TODO or catch the exception and send error +        provider = parts[2] +        d = eip.do_start(provider) +        return d + +    @register_method('dict') +    def do_STOP(self, eip, *parts): +        d = eip.do_stop() +        return d + + +class MailCmd(SubCommand): + +    label = 'mail' + +    @register_method('dict') +    def do_ENABLE(self, service, *parts, **kw): +        # FIXME -- service doesn't have this method +        d = service.do_enable_service(self.label) +        return d + +    @register_method('dict') +    def do_DISABLE(self, service, *parts, **kw): +        d = service.do_disable_service(self.label) +        return d + +    @register_method('dict') +    def do_STATUS(self, mail, *parts, **kw): +        d = mail.do_status() +        return d + +    @register_method('dict') +    def do_GET_TOKEN(self, mail, *parts, **kw): +        d = mail.get_token() +        return d + +    @register_method('dict') +    def do_GET_SMTP_CERTIFICATE(self, mail, *parts, **kw): +        # 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 mail.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) + +        bonafide = kw['bonafide'] +        d = bonafide.do_get_smtp_cert() +        d.addCallback(save_cert) +        return d + + +class KeysCmd(SubCommand): + +    label = 'keys' + +    @register_method("[dict]") +    def do_LIST(self, service, *parts, **kw): +        private = False +        if parts[-1] == 'private': +            private = True + +        bonafide = kw['bonafide'] +        d = bonafide.do_get_active_user() +        d.addCallback(service.do_list_keys, private) +        return d + +    @register_method('dict') +    def do_EXPORT(self, service, *parts, **kw): +        if len(parts) < 3: +            return defer.fail("An email address is needed") +        address = parts[2] + +        private = False +        if parts[-1] == 'private': +            private = True + +        bonafide = kw['bonafide'] +        d = bonafide.do_get_active_user() +        d.addCallback(service.do_export, address, private) +        return d + +    @register_method('dict') +    def do_INSERT(self, service, *parts, **kw): +        if len(parts) < 5: +            return defer.fail("An email address is needed") +        address = parts[2] +        validation = parts[3] +        rawkey = parts[4] + +        bonafide = kw['bonafide'] +        d = bonafide.do_get_active_user() +        d.addCallback(service.do_insert, address, rawkey, validation) +        return d + +    @register_method('str') +    def do_DELETE(self, service, *parts, **kw): +        if len(parts) < 3: +            return defer.fail("An email address is needed") +        address = parts[2] + +        private = False +        if parts[-1] == 'private': +            private = True + +        bonafide = kw['bonafide'] +        d = bonafide.do_get_active_user() +        d.addCallback(service.do_delete, address, private) +        return d + + +class CommandDispatcher(object): + +    __metaclass__ = APICommand + +    label = 'core' + +    def __init__(self, core): + +        self.core = core +        self.subcommand_user = UserCmd() +        self.subcommand_eip = EIPCmd() +        self.subcommand_mail = MailCmd() +        self.subcommand_keys = KeysCmd() + +    # XXX -------------------------------------------- +    # TODO move general services to another subclass + +    @register_method("{'mem_usage': str}") +    def do_STATS(self, *parts): +        return _format_result(self.core.do_stats()) + +    @register_method("{version_core': '0.0.0'}") +    def do_VERSION(self, *parts): +        return _format_result(self.core.do_version()) + +    @register_method("{'mail': 'running'}") +    def do_STATUS(self, *parts): +        return _format_result(self.core.do_status()) + +    @register_method("{'shutdown': 'ok'}") +    def do_SHUTDOWN(self, *parts): +        return _format_result(self.core.do_shutdown()) + +    # ----------------------------------------------- + +    def do_USER(self, *parts): +        bonafide = self._get_service('bonafide') +        d = self.subcommand_user.dispatch(bonafide, *parts) +        d.addCallbacks(_format_result, _format_error) +        return d + +    def do_EIP(self, *parts): +        eip = self._get_service(self.subcommand_eip.label) +        if not eip: +            return _format_result('eip: disabled') +        subcmd = parts[1] + +        dispatch = self._subcommand_eip.dispatch +        if subcmd in ('enable', 'disable'): +            d = dispatch(self.core, *parts) +        else: +            d = dispatch(eip, *parts) + +        d.addCallbacks(_format_result, _format_error) +        return d + +    def do_MAIL(self, *parts): +        subcmd = parts[1] +        dispatch = self.subcommand_mail.dispatch + +        if subcmd == 'enable': +            d = dispatch(self.core, *parts) + +        mail = self._get_service(self.subcommand_mail.label) +        bonafide = self._get_service('bonafide') +        kw = {'bonafide': bonafide} + +        if not mail: +            return _format_result('mail: disabled') + +        if subcmd == 'disable': +            d = dispatch(self.core) +        else: +            d = dispatch(mail, *parts, **kw) + +        d.addCallbacks(_format_result, _format_error) +        return d + +    def do_KEYS(self, *parts): +        dispatch = self.subcommand_keys.dispatch + +        keymanager_label = 'keymanager' +        keymanager = self._get_service(keymanager_label) +        bonafide = self._get_service('bonafide') +        kw = {'bonafide': bonafide} + +        if not keymanager: +            return _format_result('keymanager: disabled') + +        d = dispatch(keymanager, *parts, **kw) +        d.addCallbacks(_format_result, _format_error) +        return d + +    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 _get_service(self, name): +        try: +            return self.core.getServiceNamed(name) +        except KeyError: +            return None + + +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/dummy.py b/src/leap/bitmask/core/dummy.py new file mode 100644 index 0000000..7b6be39 --- /dev/null +++ b/src/leap/bitmask/core/dummy.py @@ -0,0 +1,80 @@ +# -*- coding: utf-8 -*- +# dummy.py +# Copyright (C) 2016 LEAP Encryption Acess Project +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program.  If not, see <http://www.gnu.org/licenses/>. +""" +An authoritative dummy backend for tests. +""" +import json + +from leap.common.service_hooks import HookableService + + +class BackendCommands(object): + +    """ +    General commands for the BitmaskBackend Core Service. +    """ + +    def __init__(self, core): +        self.core = core + +    def do_status(self): +        return json.dumps( +            {'soledad': 'running', +             'keymanager': 'running', +             'mail': 'running', +             'eip': 'stopped', +             'backend': 'dummy'}) + +    def do_version(self): +        return {'version_core': '0.0.1'} + +    def do_stats(self): +        return {'mem_usage': '01 KB'} + +    def do_shutdown(self): +        return {'shutdown': 'ok'} + + +class mail_services(object): + +    class SoledadService(HookableService): +        pass + +    class KeymanagerService(HookableService): +        pass + +    class StandardMailService(HookableService): +        pass + + +class BonafideService(HookableService): + +    def __init__(self, basedir): +        pass + +    def do_authenticate(self, user, password): +        return {u'srp_token': u'deadbeef123456789012345678901234567890123', +                u'uuid': u'01234567890abcde01234567890abcde'} + +    def do_signup(self, user, password): +        return {'signup': 'ok', 'user': 'dummyuser@provider.example.org'} + +    def do_logout(self, user): +        return {'logout': 'ok'} + +    def do_get_active_user(self): +        return 'dummyuser@provider.example.org' diff --git a/src/leap/bitmask/core/flags.py b/src/leap/bitmask/core/flags.py new file mode 100644 index 0000000..9a40c70 --- /dev/null +++ b/src/leap/bitmask/core/flags.py @@ -0,0 +1 @@ +BACKEND = 'default' diff --git a/src/leap/bitmask/core/launcher.py b/src/leap/bitmask/core/launcher.py new file mode 100644 index 0000000..b8916a1 --- /dev/null +++ b/src/leap/bitmask/core/launcher.py @@ -0,0 +1,47 @@ +# -*- 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 <http://www.gnu.org/licenses/>. +""" +Run bitmask daemon. +""" +from os.path import join +from sys import argv + +from twisted.scripts.twistd import run + +from leap.bitmask.util import here +from leap.bitmask import core +from leap.bitmask.core import flags + + +def run_bitmaskd(): +    # TODO --- configure where to put the logs... (get --logfile, --logdir +    # from the bitmask_cli +    for (index, arg) in enumerate(argv): +        if arg == '--backend': +            flags.BACKEND = argv[index + 1] +    argv[1:] = [ +        '-y', join(here(core), "bitmaskd.tac"), +        '--pidfile', '/tmp/bitmaskd.pid', +        '--logfile', '/tmp/bitmaskd.log', +        '--umask=0022', +    ] +    print '[+] launching bitmaskd...' +    run() + + +if __name__ == "__main__": +    run_bitmaskd() diff --git a/src/leap/bitmask/core/mail_services.py b/src/leap/bitmask/core/mail_services.py new file mode 100644 index 0000000..68cb444 --- /dev/null +++ b/src/leap/bitmask/core/mail_services.py @@ -0,0 +1,690 @@ +# -*- coding: utf-8 -*- +# mail_services.py +# Copyright (C) 2016 LEAP Encryption Acess Project +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program.  If not, see <http://www.gnu.org/licenses/>. +""" +Mail services. + +This is quite moving work still. +This should be moved to the different packages when it stabilizes. +""" +import json +import os +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 +from leap.keymanager.errors import KeyNotFound +from leap.keymanager.validation import ValidationLevels +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 +from leap.bitmask.core.configurable import DEFAULT_BASEDIR + + +class Container(object): + +    def __init__(self, service=None): +        self._instances = defaultdict(None) +        if service is not None: +            self.service = service + +    def get_instance(self, key): +        return self._instances.get(key, None) + +    def add_instance(self, key, data): +        self._instances[key] = data + + +class ImproperlyConfigured(Exception): +    pass + + +class SoledadContainer(Container): + +    def __init__(self, service=None, basedir=DEFAULT_BASEDIR): +        self._basedir = os.path.expanduser(basedir) +        self._usermap = UserMap() +        super(SoledadContainer, self).__init__(service=service) + +    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) + +        super(SoledadContainer, self).add_instance(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, soledad_path, +                                 server_url, cert_file, token): +        # setup soledad info +        secrets_path = os.path.join(soledad_path, '%s.secret' % uuid) +        local_db_path = os.path.join(soledad_path, '%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(HookableService): + +    def __init__(self, basedir): +        service.Service.__init__(self) +        self._basedir = basedir + +    def startService(self): +        log.msg('Starting Soledad Service') +        self._container = SoledadContainer(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, service=None, basedir=DEFAULT_BASEDIR): +        self._basedir = os.path.expanduser(basedir) +        super(KeymanagerContainer, self).__init__(service=service) + +    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 +        super(KeymanagerContainer, self).add_instance(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() +            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() +            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, 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) + +        # TODO use the method in +        # services.soledadbootstrapper._get_gpg_bin_path. +        # That should probably live in keymanager package. + +        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(HookableService): + +    def __init__(self, basedir=DEFAULT_BASEDIR): +        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... %s ' % token) +                self.tokens[userid] = token + +    # commands + +    def do_list_keys(self, userid, private=False): +        km = self._container.get_instance(userid) +        d = km.get_all_keys(private=private) +        d.addCallback(lambda keys: [dict(key) for key in keys]) +        return d + +    def do_export(self, userid, address, private=False): +        km = self._container.get_instance(userid) +        d = km.get_key(address, private=private, fetch_remote=False) +        d.addCallback(lambda key: dict(key)) +        return d + +    def do_insert(self, userid, address, rawkey, validation='Fingerprint'): +        km = self._container.get_instance(userid) +        validation = ValidationLevels.get(validation) +        d = km.put_raw_key(rawkey, address, validation=validation) +        d.addCallback(lambda _: km.get_key(address, fetch_remote=False)) +        d.addCallback(lambda key: dict(key)) +        return d + +    @defer.inlineCallbacks +    def do_delete(self, userid, address, private=False): +        km = self._container.get_instance(userid) +        key = yield km.get_key(address, private=private, fetch_remote=False) +        km.delete_key(key) +        defer.returnValue(key.fingerprint) + + +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._service_tokens = {} +        self._active_user = None +        super(StandardMailService, self).__init__() +        self.initializeChildrenServices() + +    def initializeChildrenServices(self): +        self.addService(IMAPService(self._soledad_sessions)) +        self.addService(SMTPService( +            self._soledad_sessions, self._keymanager_sessions, +            self._sendmail_opts)) +        # TODO adapt the service to receive soledad/keymanager sessions object. +        # See also the TODO before IncomingMailService.startInstance +        self.addService(IncomingMailService(self)) + +    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 registerToken(token): +            self._service_tokens[userid] = token +            self._active_user = userid + +        d = soledad.get_or_create_service_token('mail_auth') +        d.addCallback(registerToken) +        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): +        status = 'running' if self.running else 'disabled' +        return {'mail': status} + +    def get_token(self): +        active_user = self._active_user +        if not active_user: +            return defer.succeed({'user': None}) +        token = self._service_tokens.get(active_user) +        return defer.succeed({'user': active_user, 'token': 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=DEFAULT_BASEDIR): + +        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, userid) +        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  <describe>. +    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 0000000..99132c2 --- /dev/null +++ b/src/leap/bitmask/core/service.py @@ -0,0 +1,209 @@ +# -*- 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 <http://www.gnu.org/licenses/>. +""" +Bitmask-core Service. +""" +import json +import resource + +from twisted.internet import reactor +from twisted.python import log + +from leap.bitmask import __version__ +from leap.bitmask.core import configurable +from leap.bitmask.core import _zmq +from leap.bitmask.core import flags +from leap.common.events import server as event_server +# from leap.vpn import EIPService + + +backend = flags.BACKEND + +if backend == 'default': +    from leap.bitmask.core import mail_services +    from leap.bonafide.service import BonafideService +elif backend == 'dummy': +    from leap.bitmask.core.dummy import mail_services +    from leap.bitmask.core.dummy import BonafideService +else: +    raise RuntimeError('Backend not supported') + + +class BitmaskBackend(configurable.ConfigurableService): + +    def __init__(self, basedir=configurable.DEFAULT_BASEDIR): + +        configurable.ConfigurableService.__init__(self, basedir) +        self.core_commands = BackendCommands(self) + +        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): +        # FIXME -- land EIP into leap.vpn +        pass +        # self._maybe_start_service('eip', EIPService) + +    def init_zmq(self): +        zs = _zmq.ZMQServerService(self) +        zs.setServiceParent(self) + +    def init_web(self): +        from leap.bitmask.core import websocket +        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 + +    def do_stats(self): +        return self.core_commands.do_stats() + +    def do_status(self): +        return self.core_commands.do_status() + +    def do_version(self): +        return self.core_commands.do_version() + +    def do_shutdown(self): +        return self.core_commands.do_shutdown() + +    # Service Toggling + +    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' + + +class BackendCommands(object): + +    """ +    General commands for the BitmaskBackend Core Service. +    """ + +    def __init__(self, core): +        self.core = core + +    def do_status(self): +        # we may want to make this tuple a class member +        services = ('soledad', 'keymanager', 'mail', 'eip') + +        status = {} +        for name in services: +            _status = 'stopped' +            try: +                if self.core.getServiceNamed(name).running: +                    _status = 'running' +            except KeyError: +                pass +            status[name] = _status +        status['backend'] = flags.BACKEND + +        return json.dumps(status) + +    def do_version(self): +        return {'version_core': __version__} + +    def do_stats(self): +        log.msg('BitmaskCore Service STATS') +        mem = resource.getrusage(resource.RUSAGE_SELF).ru_maxrss +        return {'mem_usage': '%s KB' % (mem / 1024)} + +    def do_shutdown(self): +        self.core.stopService() +        reactor.callLater(1, reactor.stop) +        return {'shutdown': 'ok'} diff --git a/src/leap/bitmask/core/uuid_map.py b/src/leap/bitmask/core/uuid_map.py new file mode 100644 index 0000000..5edc721 --- /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 <http://www.gnu.org/licenses/>. +""" +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 0000000..e69de29 --- /dev/null +++ b/src/leap/bitmask/core/web/__init__.py diff --git a/src/leap/bitmask/core/web/index.html b/src/leap/bitmask/core/web/index.html new file mode 100644 index 0000000..9490eca --- /dev/null +++ b/src/leap/bitmask/core/web/index.html @@ -0,0 +1,70 @@ +<!DOCTYPE html> +<html> +   <head> +      <title>Bitmask WebSockets Endpoint</title> +      <script type="text/javascript"> +         var sock = null; +         var ellog = null; + +         window.onload = function() { + +            ellog = document.getElementById('log'); + +            var wsuri; +            if (window.location.protocol === "file:") { +               wsuri = "ws://127.0.0.1:8080/ws"; +            } else { +               wsuri = "ws://" + window.location.hostname + ":8080/bitmask"; +            } + +            if ("WebSocket" in window) { +               sock = new WebSocket(wsuri); +            } else if ("MozWebSocket" in window) { +               sock = new MozWebSocket(wsuri); +            } else { +               log("Browser does not support WebSocket!"); +               window.location = "http://autobahn.ws/unsupportedbrowser"; +            } + +            if (sock) { +               sock.onopen = function() { +                  log("Connected to " + wsuri); +               } + +               sock.onclose = function(e) { +                  log("Connection closed (wasClean = " + e.wasClean + ", code = " + e.code + ", reason = '" + e.reason + "')"); +                  sock = null; +               } + +               sock.onmessage = function(e) { +                  log("[res] " + e.data + '\n'); +               } +            } +         }; + +         function send() { +            var msg = document.getElementById('message').value; +            if (sock) { +               sock.send(msg); +               log("[cmd] " + msg); +            } else { +               log("Not connected."); +            } +         }; + +         function log(m) { +            ellog.innerHTML += m + '\n'; +            ellog.scrollTop = ellog.scrollHeight; +         }; +      </script> +   </head> +   <body> +      <h1>Bitmask Control Panel</h1> +      <noscript>You must enable JavaScript</noscript> +      <form> +         <p>Command: <input id="message" type="text" size="50" maxlength="50" value="status"></p> +      </form> +      <button onclick='send();'>Send Command</button> +      <pre id="log" style="height: 20em; overflow-y: scroll; background-color: #faa;"></pre> +   </body> +</html> diff --git a/src/leap/bitmask/core/web/root.py b/src/leap/bitmask/core/web/root.py new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/src/leap/bitmask/core/web/root.py diff --git a/src/leap/bitmask/core/websocket.py b/src/leap/bitmask/core/websocket.py new file mode 100644 index 0000000..5569c6c --- /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 <http://www.gnu.org/licenses/>. + +""" +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) | 
