diff options
Diffstat (limited to 'src/leap')
-rw-r--r-- | src/leap/mx/alias_resolver.py | 429 | ||||
-rw-r--r-- | src/leap/mx/couchdbhelper.py (renamed from src/leap/mx/couchdb.py) | 26 | ||||
-rw-r--r-- | src/leap/mx/mail_receiver.py | 157 |
3 files changed, 161 insertions, 451 deletions
diff --git a/src/leap/mx/alias_resolver.py b/src/leap/mx/alias_resolver.py index f71c4d8..eb08c06 100644 --- a/src/leap/mx/alias_resolver.py +++ b/src/leap/mx/alias_resolver.py @@ -1,411 +1,60 @@ #!/usr/bin/env python # -*- encoding: utf-8 -*- -''' -alias_resolver.py -================= +# alias_resolver.py +# Copyright (C) 2013 LEAP +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see <http://www.gnu.org/licenses/>. + +""" Classes for resolving postfix aliases. -:authors: Isis Agora Lovecruft -:version: 0.0.1-beta -:license: see included LICENSE file -:copyright: (c) 2013 Isis Agora Lovecruft - TODO: - o Look into using twisted.protocols.postfix.policies classes for controlling concurrent connections and throttling resource consumption. -''' +""" -import os -import uuid +import logging try: - from twisted.internet import address, defer, reactor - from twisted.mail import maildir, alias + # TODO: we should probably use the system alias somehow + # from twisted.mail import alias from twisted.protocols import postfix except ImportError: print "This software requires Twisted. Please see the README file" print "for instructions on getting required dependencies." -from leap.mx import couchdb -from leap.mx.util import net, log, config, exceptions - - -def createUUID(alias): - """Creates Universal Unique ID by taking the SHA1 HASH of an email alias: - - >>> uuid.uuid5(uuid.NAMESPACE_URL, "isis@leap.se") - UUID('7194878e-4aea-563f-85a4-4f58519f3c4f') - - TODO: Is there a commonly accepted way to check that an email address - is valid? - - :param str alias: An email address alias. - :returns: A :class:`uuid.UUID` containing attributes specifying the UUID. - """ - return uuid.uuid5(uuid.NAMESPACE_URL, str(alias)) - - -class DatabaseNotConnected(Exception): - """Raised when not currently connected to a database.""" - -class StatusCodes(object): - """The Postfix manual states: - - The request completion status is one of OK, RETRY, NOKEY (lookup failed - because the key was not found), BAD (malformed request) or DENY (the - table is not approved for proxy read or update access). - - In brief, Postfix will send ``get SPACE key NEWLINE``, or - ``put SPACE key NEWLINE`` where ``key`` is an alias or email address. - It expects non-printable ascii characters to be url-encoded, i.e. a - get-request would look like: - - ``get%20isis@leap.se%0A`` - - and in response, Postfix expects an SMTP-like status code and a string - describing the nature of or reason for the response, no longer than - 4096 "characters" (which, due to UTF-8 ubiquity, we'll err on the safe - side and assume that means 4096 bytes.) - - From the Postfix manual on its TCP map protocol - (http://www.postfix.org/tcp_table.5.html): - - 500 SPACE text NEWLINE - In case of a lookup request, the requested data - does not exist. In case of an update request, the - request was rejected. The text describes the - nature of the problem. - - 400 SPACE text NEWLINE - This indicates an error condition. The text - describes the nature of the problem. The client - should retry the request later. - - 200 SPACE text NEWLINE - The request was successful. In the case of a lookup - request, the text contains an encoded version of - the requested data. - - Other SMTP codes: http://www.greenend.org.uk/rjk/tech/smtpreplies.html - - >>> statcodes = StatusCodes() - >>> if <query local user database for email address>: - >>> response_message = statcodes(200) - >>> aliasresolver.tellMTA() - """ - - OK = "OK Others might say 'HELLA AWESOME'...but we're not convinced." - RETRY = "RETRY Server is busy plotting revolution; requests might take a while." - BAD = "BAD bad Leroy Brown, baddest man in the whole...er. Malformed request." - NOKEY = "NOKEY Couldn't find your keys, sorry. Did you check in the sofa?" - DEFER = "DEFER_IF_LOCAL xxx fill me in" - DENY = "DENY no gurlz aloud in teh tree house." - FAIL = "FAIL this belongs on the failblog" - - SMTPCodes = { '200': OK, - '400': RETRY, - '500': BAD, - '550': NOKEY, - '552': DEFER, - '553': DENY, - '554': FAIL, } - - SMTPStrings = { 'OK' 200, - 'RETRY': 400, - 'BAD': 500, - 'NOKEY': 550, - 'DEFER': 552, - 'DENY': 553, - 'FAIL': 554, } - - def __init__(self, status_code=None): - """Construct an SMTP status code generator. - - :type status_code: str or int - :param status_code: (optional) see :func:`StatusCode.get`. - """ - if status_code: - self.get(status_code) - - def get(self, status_code=None) - """Takes an SMTP-like status code and returns an SMTP-like message. - - :type status_code: str or int - :param status_code: The string or integer for the response we want - to give back to the MTA, after looking up an - email address in the local user database. - Can be one of: - * ``OK`` or ``200`` - * ``RETRY`` or ``400`` - * ``BAD`` or ``500`` - * ``NOKEY`` or ``550`` - * ``DEFER`` or ``552`` - * ``DENY`` or ``553`` - * ``FAIL`` or ``554`` - """ - if status_code: - if isinstance(status_code, str): - if status_code.upper() in self.SMTPStrings.keys(): - return self.SMTPStrings[status_code], getattr( - self, status_code.upper(), '') - else: - return 500, self.FAIL - elif isinstance(status_code, int): - for k, v in self.SMTPCodes.items(): - ## we want to return None if it's 500 - if k == str(status_code) and k != '500': - return status_code, v - log.debug("%s" % self.NOKEY) - return None, '' - - -class AliasResolver(postfix.PostfixTCPMapServer): - """Resolve postfix aliases, similarly to using "$ postmap -q <alias>". - - This class starts a simple LineReceiver server which listens for a string - specifying an alias ``key`` to look up, which will be used to query the - local user database. You can test it with: - - $ ./alias_resolver.py & - $ /usr/bin/postmap -q <key> tcp:localhost:1347 - - Resources: - http://www.postfix.org/proxymap.8.html - https://www.iana.org/assignments/smtp-enhanced-status-codes/ - """ - - virtual_transport = '@example.com' - use_virtual_transport = False - - def __init__(self, *args, **kwargs): - """Create a server which listens for Postfix aliases to resolve. - - :param int timeout: Number of seconds to wait for a response. - :param str delimiter: The delimiter to use for the EOL on responses. - (Default: '\n') - """ - super(postfix.PostfixTCPMapServer, self).__init__(*args, **kwargs) - self.status_codes = StatusCodes() - - def sendCode(self, code, message=None): - """Send an SMTP-like code with a message. - - :type code: str or int - :param code: The status code to send, see - ``alias_resolver.StatusCodes``. - """ - try: - assert isinstance(code, int), "status code must be type int" - except AssertionError as ae: - log.err(ae.message) - self.sendLine('500 internal server error: %s' % ae.message) - - msg = self.status_codes.get(code) - if message is not None and isinstance(message, str): - msg += (" " + message) - self.sendLine('%3.3d %s' % (code, message or '')) - - def do_get(self, key): - """Make a query to resolve an alias.""" - if key is None: - self.sendCode(500) - log.warn("Command 'get' takes one parameter.") - else: - d = defer.maybeDeferred(self.factory.get, key) - d.addCallbacks(self._cbGot, self._cbNot) - d.addErrback(log.err) - - @defer.inlineCallbacks - def do_put(self, keyAndValue): - """Add a key and value to the database, provided it does not exist. - - :param str keyAndValue: An alias and email address, separated by a - space, i.e. ``"isis isis@leap.se"``. - """ - if keyAndValue is None: - self.sendCode(500) - log.warn("Command 'put' takes two parameters.") - else: - try: - key, value = keyAndValue.split(None, 1) - except ValueError: - self.sendCode(500) - log.warn("Command 'put' takes two parameters.") - else: - alreadyThere = yield self.do_query(key) - if alreadyThere is None: - d = defer.maybeDeferred(self.factory.put, key, value) - d.addCallbacks(self._cbPut, self._cbPout) - d.addCallbacks(log.err) - else: - self.sendCode(553) - - @defer.inlineCallbacks - def do_delete(self, key): - """Delete an alias from the CouchDB. - - xxx I'm not sure if implementing this would be a good idea... - - :param str key: An email address to delete from the CouchDB. - """ - raise NotImplemented - - def check_recipient_access(self, key): - """Make a query to the CouchDB to resolve an alias. - - If the ``key`` is an email address which the CouchDB has information - for that account, we should respond to Postfix with an '200%20\n". - - :param str key: An email address to look up in the CouchDB. - """ - return self.do_get(key) - - def virtual_alias_map(self, key): - """Get the Universal Unique ID for the alias address. If - virtual_transport is True, then suffix the UUID with a domain. - - xxx I don't think we actually need couchdb for this, the UUID is an - identifier, not an authenticator. And the SHA1 should always be the - same, so unless it's considered to expensive to compute (less than - querying a database, I would presume), it seems silly to do this. - - Instead, we should query CouchDB with the UUID to get the GPG keyid. - - xxx Or are we supposed to query Soledad for this? - - :param str key: An email address to look up in the CouchDB. - :returns: The UUID of the user. - """ - ## xxx need email address parser - userid = createUUID(key) - - if self.use_virtual_transport \ - and isinstance(self.virtual_transport, str): - return userid.get_urn() + self.virtual_transport - else: - return userid.get_urn() +logger = logging.getLogger(__name__) class AliasResolverFactory(postfix.PostfixTCPMapDeferringDictServerFactory): - """A Factory for creating :class:`AliasResolver` servers, which handles - inputs and outputs, and keeps an in-memory mapping of Postfix aliases in - the form of a dictionary. - - >>> from leap.mx import alias_resolver - >>> aliasResolverFactory = alias_resolver.AliasResolver( - ... data={'isis': 'isis@leap.se', - ... 'drebs': 'drebs@leap.se', - ... 'elijah': 'elijah@leap.se',}) - >>> aliasResolver = aliasResolverFactory.buildProtocol() - >>> aliasResolver.check_recipient_access('isis') - """ - protocol = AliasResolver - database = couchdb.ConnectedCouchDB - - def __init__(self, addr='127.0.0.1', port=4242, timeout=120, - data=None, virtual_transport=None, use_virtual_transport=False, - couch_host=None, couch_port=None, couch_dbname='users', - couch_username=None, couch_password=None): - """Create a Factory which returns :class:`AliasResolver` servers. - - :param str addr: A string giving the IP address of this server, for - talking to postfix. Default: '127.0.0.1' - :param int port: An integer that specifies the port number that this - server should listen and respond on, for talking to - Postfix. on. Default: 4242 - :param int timeout: An integer specifying the number of seconds to wait - until we should time out. Default: 120 - :param dict data: A dict to use to initialise or update the alias - mapping. - :param str virtual_transport: The domain portion of an email address - to suffix the UUID responses of - ``AliasResolver.virtual_alias_map`` with. - :param bool use_virtual_transport: If True, suffix UUIDs with the - ``virtual_transport`` string. + def __init__(self, couchdb, *args, **kwargs): + postfix.PostfixTCPMapDeferringDictServerFactory.__init__(self, *args, **kwargs) + self._cdb = couchdb - :param str couch_host: The IP address of the CouchDB server to query. - :param int couch_port: The port of the CouchDB server to query. - :param str couch_dbname: The database in the CouchDB to bind to. - :param str couch_username: The username for authenticating to the - CouchDB. - :param str couch_password: The password for authentication. - """ - super(postfix.PostfixTCPMapDeferringDictServerFactory, - self).__init__(data=data) - self.timeout = timeout - self.virtual_transport = virtual_transport - self.use_virtual_transport = use_virtual_transport - self.noisy = True if config.advanced.noisy else False - - if couch_port is None: - couch_port = 5984 - if couch_dbname is None: - couch_dbname = 'users' - self.database_connected = False - if couch_host is not None: - self.couch = self.connectDatabase(couch_host, couch_port, - couch_dbname, couch_username, - couch_password) + def _to_str(self, result): + if isinstance(result, unicode): + result = result.encode("utf8") + return result + def get(self, key): + orig_key = key try: - assert isinstance(port, int), "Port number must be an integer" - assert isinstance(timeout, int), "Timeout must be an integer" - except AssertionError, ae: - raise SystemExit(ae.message) - - if net.checkIPaddress(addr): - self.addr = address._IPAddress('TCP', addr, int(port)) - else: - log.msg("Using default address: 127.0.0.1:%s" % port) - self.addr = address._IPAddress('TCP', '127.0.0.1', int(port)) - - log.msg("To configure Postfix to query this alias_resolver,") - log.msg("you should do:") - log.msg(" $ postconf -e 'check_recipient_access = tcp:%s:%d" - % (addr, port)) - - def buildProtocol(self): - """Create an instance of the :class:`AliasResolver` server.""" - proto = self.protocol() - proto.timeout = self.timeout - proto.virtual_transport = self.virtual_transport - proto.use_virtual_transport = self.use_virtual_transport - proto.factory = self - return proto - - def _cb_connectDatabase(self): - self.database_connected = True - - def connectDatabase(self, couch_host, couch_port=None, couch_dbname=None, - couch_username=None, couch_password=None): - """Connect to the CouchDB instance.""" - if not self.database_connected: - d = self.database(couch_host, couch_port, dbName=couch_dbname, - username=couch_username, password=couch_password) - d.addCallback(self._cb_connectDatabase) - d.addErrback(log.err) - return d - else: - return self.couch ## xxx are we sure we only want one connection? - - def get(self, key, **kwargs): - """Query the CouchDB for a user's info. - - :param str key: The alias to look up. Should be either an email address - or a username. (xxx do we want to also support lookups - by UUID?) - """ - if self.database_connected: - return self.couch.queryByEmailOrAlias(key) - else: - raise DatabaseNotConnected("Must be connected to a database.") - - def put(self, key, **kwargs): - """Add an alias to the CouchDB database.""" - raise NotImplemented - - -if __name__ == "__main__": - - print "To test alias_resolver.py, please use /test/test_alias_resolver.py" + key = key.split("@")[0] + key = key.split("+")[0] + except Exception as e: + key = orig_key + logger.exception("%s" % (e,)) + d = self._cdb.queryByLoginOrAlias(key) + d.addCallback(self._to_str) + return d diff --git a/src/leap/mx/couchdb.py b/src/leap/mx/couchdbhelper.py index 277d356..cbb087d 100644 --- a/src/leap/mx/couchdb.py +++ b/src/leap/mx/couchdbhelper.py @@ -15,12 +15,10 @@ # You should have received a copy of the GNU General Public License # along with this program. If not, see <http://www.gnu.org/licenses/>. -''' -couchdb.py -========== +""" Classes for working with CouchDB or BigCouch instances which store email alias maps, user UUIDs, and GPG keyIDs. -''' +""" try: from paisley import client @@ -70,6 +68,9 @@ class ConnectedCouchDB(client.CouchDB): username=username, password=password, *args, **kwargs) + + self._cache = {} + if dbName is None: databases = self.listDB() databases.addCallback(self._print_databases) @@ -108,6 +109,8 @@ class ConnectedCouchDB(client.CouchDB): """ assert isinstance(alias, str), "Email or alias queries must be string" + # TODO: Cache results + d = self.openView(docId="User", viewId="by_login_or_alias/", key=alias, @@ -131,7 +134,19 @@ class ConnectedCouchDB(client.CouchDB): """ for row in result["rows"]: if row["key"] == alias: - return row["id"] + uuid = row["id"] + self._cache[uuid] = row["value"] + return uuid + return None + + + def getPubKey(self, uuid): + pubkey = None + try: + pubkey = self._cache[uuid] + except: + pass + return pubkey if __name__ == "__main__": @@ -146,6 +161,7 @@ if __name__ == "__main__": @d.addCallback def right(result): print "Should be an actual uuid:", result + print cdb.getPubKey(result) d2 = cdb.queryByLoginOrAlias("asdjaoisdjoiqwjeoi") @d2.addCallback diff --git a/src/leap/mx/mail_receiver.py b/src/leap/mx/mail_receiver.py index 9636591..09200ac 100644 --- a/src/leap/mx/mail_receiver.py +++ b/src/leap/mx/mail_receiver.py @@ -1,68 +1,97 @@ +#!/usr/bin/env python +# -*- encoding: utf-8 -*- +# mail_receiver.py +# Copyright (C) 2013 LEAP +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see <http://www.gnu.org/licenses/>. + import os -import pyinotify import logging import argparse import ConfigParser from email import message_from_string +from functools import partial -logger = logging.getLogger(name='leap_mx') +from twisted.internet import inotify, reactor +from twisted.python import filepath +from leap.mx import couchdbhelper +from leap.soledad.backends.couch import CouchDatabase -def _get_uuid(uid, user, password, server): - # TODO: implement! - return "" +logger = logging.getLogger(__name__) -def _get_pubkey(uuid, user, password, server): +def _get_pubkey(uuid): # TODO: implent! - return "" + logger.debug("Fetching pubkey for %s" % (uuid,)) + return uuid, "" -def _encrypt_message(pubkey, message): +def _encrypt_message(uuid_pubkey, message): # TODO: implement! - return message + uuid, pubkey = uuid_pubkey + logger.debug("Encrypting message to %s's pubkey" % (uuid,)) + logger.debug("Pubkey: %s" % (pubkey,)) + + if pubkey is None or len(pubkey) == 0: + # TODO: This is only for testing!! REMOVE! + return uuid, message + + encrypted = "" + + return uuid, encrypted + + +def _export_message(uuid_message, couch_url): + uuid, message = uuid_message + logger.debug("Exporting message for %s" % (uuid,)) + + if uuid is None: + uuid = 0 + db_url = couch_url + '/user-%s' % uuid + db = CouchDatabase.open_database(db_url, create=True) + doc = db.create_doc({'content': str(message)}) -def _export_message(uuid, message, user, password, server): - # TODO: Implement! return True -# <Event dir=False mask=0x100 maskname=IN_CREATE name=1366132684.P9922.delloise path=Maildir/tmp pathname=Maildir/tmp/1366132684.P9922.delloise wd=2 > -# <Event dir=False mask=0x20 maskname=IN_OPEN name=1366132684.P9922.delloise path=Maildir/tmp pathname=Maildir/tmp/1366132684.P9922.delloise wd=2 > -# <Event dir=False mask=0x2 maskname=IN_MODIFY name=1366132684.P9922.delloise path=Maildir/tmp pathname=Maildir/tmp/1366132684.P9922.delloise wd=2 > -# <Event dir=False mask=0x8 maskname=IN_CLOSE_WRITE name=1366132684.P9922.delloise path=Maildir/tmp pathname=Maildir/tmp/1366132684.P9922.delloise wd=2 > -# <Event dir=False mask=0x100 maskname=IN_CREATE name=1366132684.V14I40088dM542424.delloise path=Maildir/new pathname=Maildir/new/1366132684.V14I40088dM542424.delloise wd=4 > -# <Event dir=False mask=0x200 maskname=IN_DELETE name=1366132684.P9922.delloise path=Maildir/tmp pathname=Maildir/tmp/1366132684.P9922.delloise wd=2 > - -class EventHandler(pyinotify.ProcessEvent): - def __init__(self, user, password, server, *args, **kwargs): - pyinotify.ProcessEvent.__init__(self, *args, **kwargs) - self._user = user - self._password = password - self._server = server - - def process_IN_CREATE(self, event): - if os.path.split(event.path)[-1] == "new": - logger.debug("Processing new mail at %s" % (event.pathname,)) - with open(event.pathname, "r") as f: - mail_data = f.read() - mail = message_from_string(mail_data) - owner = mail["Delivered-To"] - logger.debug("%s received a new mail" % (owner,)) - # get user uuid - uuid = _get_uuid(owner, self._user, self._password, self._server) - # get the pubkey for uuid - pubkey = _get_pubkey(uuid, self._user, self._password, self._server) - # encrypt the message to the pubkey - encrypted = _encrypt_message(pubkey, mail_data) - # save the message in a couchdb - if _export_message(uuid, encrypted, self._user, self._password, self._server): - # remove the original mail - try: - os.remove(event.pathname) - except Exception as e: - # TODO: better handle exceptions - logger.error(e.message()) + +def _conditional_remove(do_remove, filepath): + if do_remove: + # remove the original mail + try: + logger.debug("Removing %s" % (filepath.path,)) + filepath.remove() + except Exception as e: + # TODO: better handle exceptions + logger.exception("%s" % (e,)) + + +def _process_incoming_email(users_db, mail_couchdb_url_prefix, self, filepath, mask): + if os.path.split(filepath.dirname())[-1] == "new": + logger.debug("Processing new mail at %s" % (filepath.path,)) + with filepath.open("r") as f: + mail_data = f.read() + mail = message_from_string(mail_data) + owner = mail["Delivered-To"] + logger.debug("%s received a new mail" % (owner,)) + d = users_db.queryByLoginOrAlias(owner) + d.addCallback(_get_pubkey) + d.addCallback(_encrypt_message, (mail_data)) + d.addCallback(_export_message, (mail_couchdb_url_prefix)) + d.addCallback(_conditional_remove, (filepath)) + def main(): epilog = "Copyright 2012 The LEAP Encryption Access Project" @@ -88,7 +117,7 @@ def main(): level = logging.WARNING if config_file is None: - config_file = "mail_receiver.cfg" + config_file = "leap_mx.cfg" logger.setLevel(level) console = logging.StreamHandler() @@ -108,24 +137,40 @@ def main(): config = ConfigParser.ConfigParser() config.read(config_file) - user = config.get("couchdb", "user") - password = config.get("couchdb", "password") + users_user = config.get("couchdb", "users_user") + users_password = config.get("couchdb", "users_password") + + mail_user = config.get("couchdb", "mail_user") + mail_password = config.get("couchdb", "mail_password") + server = config.get("couchdb", "server") + port = config.get("couchdb", "port") + + wm = inotify.INotify(reactor) + wm.startReading() + + mask = inotify.IN_CREATE + + users_db = couchdbhelper.ConnectedCouchDB(server, + port=port, + dbName="users", + username=users_user, + password=users_password) - wm = pyinotify.WatchManager() - mask = pyinotify.IN_CREATE - handler = EventHandler(user, password, server) - notifier = pyinotify.Notifier(wm, handler) + mail_couch_url_prefix = "http://%s:%s@localhost:%s" % (mail_user, + mail_password, + port) + incoming_partial = partial(_process_incoming_email, users_db, mail_couch_url_prefix) for section in config.sections(): if section in ("couchdb"): continue to_watch = config.get(section, "path") recursive = config.getboolean(section, "recursive") logger.debug("Watching %s --- Recursive: %s" % (to_watch, recursive)) - wm.add_watch(to_watch, mask, rec=recursive) + wm.watch(filepath.FilePath(to_watch), mask, callbacks=[incoming_partial], recursive=recursive) - notifier.loop() + reactor.run() if __name__ == "__main__": main() |