diff options
| author | drebs <drebs@leap.se> | 2015-04-29 15:46:29 -0300 | 
|---|---|---|
| committer | drebs <drebs@leap.se> | 2015-04-29 15:46:29 -0300 | 
| commit | 76937040c20dd786825261e6ee2fc3766ef3d8e0 (patch) | |
| tree | b4cf68efe89d4dd5bdf4362a388be6b51c403a9d /src | |
| parent | b5ed3e4db16c8e3856b5c45409807d8f58cd957a (diff) | |
| parent | 799703cf884191d097eb5d5316fa964e421683fd (diff) | |
Merge tag '0.6.2'
Tag leap.mx version 0.6.2
Diffstat (limited to 'src')
| -rw-r--r-- | src/leap/mx/alias_resolver.py | 91 | ||||
| -rw-r--r-- | src/leap/mx/bounce.py | 526 | ||||
| -rw-r--r-- | src/leap/mx/check_recipient_access.py | 68 | ||||
| -rw-r--r-- | src/leap/mx/couchdbhelper.py | 148 | ||||
| -rw-r--r-- | src/leap/mx/mail_receiver.py | 151 | ||||
| -rw-r--r-- | src/leap/mx/tcp_map.py | 72 | 
6 files changed, 766 insertions, 290 deletions
| diff --git a/src/leap/mx/alias_resolver.py b/src/leap/mx/alias_resolver.py index 45a3ed2..c6f2acc 100644 --- a/src/leap/mx/alias_resolver.py +++ b/src/leap/mx/alias_resolver.py @@ -19,73 +19,62 @@  """  Classes for resolving postfix aliases. +The resolver is queried by the mail server before delivery to the mail spool +directory, and should return the user uuid. This way, we get rid from the user +address early and the mail server will delivery the message to +"<uuid>@<domain>". Later, the mail receiver part of MX will parse the +"Delivered-To" header to extract the uuid and fetch the user's pgp public key. +  Test this with postmap -v -q "foo" tcp:localhost:4242  TODO:      o Look into using twisted.protocols.postfix.policies classes for        controlling concurrent connections and throttling resource consumption. +    o We should probably use twisted.mail.alias somehow.  """ -try: -    # TODO: we should probably use the system alias somehow -    # from twisted.mail      import alias -    from twisted.protocols import postfix -    from twisted.python import log -    from twisted.internet import defer -except ImportError: -    print "This software requires Twisted. Please see the README file" -    print "for instructions on getting required dependencies." - -class LEAPPostFixTCPMapserver(postfix.PostfixTCPMapServer): -    def _cbGot(self, value): -        if value is None: -            self.sendCode(500, postfix.quote("NOT FOUND SRY")) -        else: -            self.sendCode(200, postfix.quote(value)) +from twisted.protocols import postfix +from leap.mx.tcp_map import LEAPPostfixTCPMapServerFactory +from leap.mx.tcp_map import TCP_MAP_CODE_SUCCESS +from leap.mx.tcp_map import TCP_MAP_CODE_PERMANENT_FAILURE -class AliasResolverFactory(postfix.PostfixTCPMapDeferringDictServerFactory): -    protocol = LEAPPostFixTCPMapserver +class LEAPPostfixTCPMapAliasServer(postfix.PostfixTCPMapServer): +    """ +    A postfix tcp map alias resolver server. +    """ -    def __init__(self, couchdb, *args, **kwargs): -        postfix.PostfixTCPMapDeferringDictServerFactory.__init__( -            self, *args, **kwargs) -        self._cdb = couchdb - -    def _to_str(self, result): -        """ -        Properly encodes the result string if any. +    def _cbGot(self, user_data):          """ -        if isinstance(result, unicode): -            result = result.encode("utf8") -        if result is None: -            log.msg("Result not found") -        return result +        Return a code and message depending on the result of the factory's +        get(). -    def spit_result(self, result): +        :param user_data: The user's uuid and pgp public key. +        :type user_data: list          """ -        Formats the return codes in a postfix friendly format. -        """ -        if result is None: -            return None +        uuid, _ = user_data +        if uuid is None: +            self.sendCode( +                TCP_MAP_CODE_PERMANENT_FAILURE, +                postfix.quote("NOT FOUND SRY"))          else: -            return defer.succeed(result) +            # properly encode uuid, otherwise twisted complains when replying +            if isinstance(uuid, unicode): +                uuid = uuid.encode("utf8") +            self.sendCode( +                TCP_MAP_CODE_SUCCESS, +                postfix.quote(uuid)) -    def get(self, key): -        """ -        Looks up the passed key, but only up to the username id of the key. -        At some point we will have to consider the domain part too. -        """ -        try: -            log.msg("Query key: %s" % (key,)) -            d = self._cdb.queryByAddress(key) +class AliasResolverFactory(LEAPPostfixTCPMapServerFactory): +    """ +    A factory for postfix tcp map alias resolver servers. +    """ + +    protocol = LEAPPostfixTCPMapAliasServer -            d.addCallback(self._to_str) -            d.addCallback(self.spit_result) -            d.addErrback(log.err) -            return d -        except Exception as e: -            log.err('exception in get: %r' % e) +    @property +    def _query_message(self): +        return "Resolving alias for" diff --git a/src/leap/mx/bounce.py b/src/leap/mx/bounce.py new file mode 100644 index 0000000..2ece6df --- /dev/null +++ b/src/leap/mx/bounce.py @@ -0,0 +1,526 @@ +#!/usr/bin/env python +# -*- encoding: utf-8 -*- +# bounce.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/>. + + +""" +Everything you need to correctly bounce a message! + +This is built from the following RFCs: + +  * The Multipart/Report Media Type for the Reporting of Mail System +    Administrative Messages +    https://tools.ietf.org/html/rfc6522 + +  * Recommendations for Automatic Responses to Electronic Mail +    https://tools.ietf.org/html/rfc3834 + +  * An Extensible Message Format for Delivery Status Notifications +    https://tools.ietf.org/html/rfc3464 +""" + + +import re +import socket + +from StringIO import StringIO +from textwrap import wrap + +from email.errors import MessageError +from email.message import Message +from email.utils import formatdate +from email.utils import parseaddr +from email.mime.base import MIMEBase +from email.mime.multipart import MIMEMultipart +from email.mime.text import MIMEText +from email.generator import Generator +from email.generator import NL + +from twisted.internet import defer +from twisted.internet import protocol +from twisted.internet import reactor +from twisted.internet.error import ProcessDone +from twisted.python import log + + +EMAIL_ADDRESS_REGEXP = re.compile("[^@]+@[^@]+\.[^@]+") +HOSTNAME = socket.gethostbyaddr(socket.gethostname())[0] + + +def _valid_address(address): +    """ +    Return whether address is a valid email address. + +    :param address: An email address candidate. +    :type address: str + +    :return: Whether address is valid. +    :rtype: bool +    """ +    return bool(EMAIL_ADDRESS_REGEXP.match(address)) + + +def bounce_message(bounce_from, bounce_subject, orig_msg, reason): +    """ +    Bounce a message. + +    :param bounce_from: The sender of the bounce message. +    :type bounce_from: str +    :param bounce_subject: The subject of the bounce message. +    :type bounce_subject: str +    :param orig_msg: The original message that will be bounced. +    :type orig_msg: email.message.Message +    :param reason: The reason for bouncing the message. +    :type reason: str + +    :return: A deferred that will fire with the output of the sendmail process +             if it was successful or with a failure containing the reason for +             the end of the process if it failed. +    :rtype: Deferred +    """ +    orig_rpath = orig_msg.get("Return-Path") + +    # do not bounce if sender address is invalid +    _, addr = parseaddr(orig_rpath) +    if not _valid_address(addr): +        log.msg( +            "Will not send a bounce message to an invalid address: %s" +            % orig_rpath) +        return + +    msg = _build_bounce_message( +        bounce_from, bounce_subject, orig_msg, reason) +    return _async_check_output(["/usr/sbin/sendmail", "-t"], msg.as_string()) + + +def _check_valid_return_path(return_path): +    """ +    Check if a certain return path is valid. + +    From RFC 3834: + +      Responders MUST NOT generate any response for which the +      destination of that response would be a null address (e.g., an +      address for which SMTP MAIL FROM or Return-Path is <>), since the +      response would not be delivered to a useful destination. +      Responders MAY refuse to generate responses for addresses commonly +      used as return addresses by responders - e.g., those with local- +      parts matching "owner-*", "*-request", "MAILER-DAEMON", etc. +      Responders are encouraged to check the destination address for +      validity before generating the response, to avoid generating +      responses that cannot be delivered or are unlikely to be useful. + +    :return: Whether the return_path is valid. +    :rtype: bool +    """ +    _, addr = parseaddr(return_path) + +    # check null address +    if not addr: +        return False + +    # check addresses commonly used as return addresses by responders +    local, _ = addr.split("@", 1) +    if local.startswith("owner-") \ +            or local.endswith("-request") \ +            or local.startswith("MAILER-DAEMON"): +        return False + +    return True + + +class DeliveryStatusNotificationMessage(MIMEBase): +    """ +    A delivery status message, as per RFC 3464. +    """ + +    def __init__(self, orig_msg): +        """ +        Initialize the DSN. +        """ +        MIMEBase.__init__(self, "message", "delivery-status") +        self.__delitem__("MIME-Version") +        self._build_dsn(orig_msg) + +    def _build_dsn(self, orig_msg): +        """ +        Build an RFC 3464 compliant delivery status message. + +        :param orig_msg: The original bouncing message. +        :type orig_msg: email.message.Message +        """ +        content = [] + +        # Per-Message DSN fields +        # ====================== + +        # Original-Envelope-Id (optional) +        envelope_id = orig_msg.get("Envelope-Id") +        if envelope_id: +            content.append("Original-Envelope-Id: %s" % envelope_id) + +        # Reporting-MTA (required) +        content.append("Reporting-MTA: dns; %s" % HOSTNAME) + +        # XXX add Arrival-Date DSN field? (optional). + +        content.append("") + +        # Per-Recipient DSN fields +        # ======================== + +        # Original-Recipient (optional) +        orig_to = orig_msg.get("X-Original-To")  # added by postfix +        _, orig_addr = parseaddr(orig_to) +        if orig_addr: +            content.append("Original-Recipient: rfc822; %s" % orig_addr) + +        # Final-Recipient (required) +        delivered_to = orig_msg.get("Delivered-To") +        content.append("Final-Recipient: rfc822; %s" % delivered_to) + +        # Action (required) +        content.append("Action: failed") + +        # Status (required) +        content.append("Status: 5.0.0")  # permanent failure + +        # XXX add other optional fields? (Remote-MTA, Diagnostic-Code, +        #     Last-Attempt-Date, Final-Log-ID, Will-Retry-Until) + +        # return a "message/delivery-status" message +        msg = Message() +        msg.set_payload("\n".join(content)) +        self.attach(msg) + + +class RFC822Headers(MIMEText): +    """ +    A text/rfc822-headers mime message as defined in RFC 6522. +    """ + +    def __init__(self, _text, **kwargs): +        """ +        Initialize the message. + +        :param _text: The contents of the message. +        :type _text: str +        """ +        MIMEText.__init__( +            self, _text, +            # set "text/rfc822-headers" mime type +            _subtype='rfc822-headers', +            **kwargs) + + +BOUNCE_TEMPLATE = """ +This is the mail system at {0}. + +I'm sorry to have to inform you that your message could not +be delivered to one or more recipients. It's attached below. + +For further assistance, please send mail to postmaster. + +If you do so, please include this problem report. You can +delete your own text from the attached returned message. + +                   The mail system + +{1} +""".strip() + + +class InvalidReturnPathError(MessageError): +    """ +    Exception raised when the return path is invalid. +    """ + + +def _build_bounce_message(bounce_from, bounce_subject, orig_msg, reason): +    """ +    Build a bounce message. + +    :param bounce_from: The sender address of the bounce message. +    :type bounce_from: str +    :param bounce_subject: The subject of the bounce message. +    :type bounce_subject: str +    :param orig_msg: The original bouncing message. +    :type orig_msg: email.message.Message +    :param reason: The reason for the bounce. +    :type reason: str + +    :return: The bounce message. +    :rtype: MIMEMultipartReport + +    :raise InvalidReturnPathError: Raised when the "Return-Path" header of the +                                   original message is invalid for creating a +                                   bounce message. +    """ +    # abort creation if "Return-Path" header is invalid +    orig_rpath = orig_msg.get("Return-Path") +    if not _check_valid_return_path(orig_rpath): +        raise InvalidReturnPathError + +    msg = MIMEMultipartReport() +    msg['From'] = bounce_from +    msg['To'] = orig_rpath +    msg['Date'] = formatdate(localtime=True) +    msg['Subject'] = bounce_subject +    msg['Return-Path'] = "<>"  # prevent bounce message loop, see RFC 3834 + +    # create and attach first required part +    orig_to = orig_msg.get("X-Original-To")  # added by postfix +    wrapped_reason = wrap(("<%s>: " % orig_to) + reason, 74) +    for i in xrange(1, len(wrapped_reason)): +        wrapped_reason[i] = "    " + wrapped_reason[i] +    wrapped_reason = "\n".join(wrapped_reason) +    text = BOUNCE_TEMPLATE.format(HOSTNAME, wrapped_reason) +    msg.attach(MIMEText(text)) + +    # create and attach second required part +    msg.attach(DeliveryStatusNotificationMessage(orig_msg)) + +    # attach third (optional) part. +    # +    # XXX From RFC 6522: +    # +    #       When 8-bit or binary data not encoded in a 7-bit form is to be +    #       returned, and the return path is not guaranteed to be 8-bit or +    #       binary capable, two options are available. The original message +    #       MAY be re-encoded into a legal 7-bit MIME message or the +    #       text/rfc822-headers media type MAY be used to return only the +    #       original message headers. +    # +    #     This is not implemented yet, we should detect if content is 7bit and +    #     use the class RFC822Headers if it is not. +#     try: +#        payload = orig_msg.get_payload() +#        payload.encode("ascii") +#    except UnicodeError: +#        headers = [] +#        for k in orig_msg.keys(): +#            headers.append("%s: %s" % (k, orig_msg[k])) +#        orig_msg = RFC822Headers("\n".join(headers)) +    msg.attach(orig_msg) + +    return msg + + +class BouncerSubprocessProtocol(protocol.ProcessProtocol): +    """ +    Bouncer subprocess protocol that will feed the msg contents to be +    bounced through stdin +    """ + +    def __init__(self, msg): +        """ +        Constructor for the BouncerSubprocessProtocol + +        :param msg: Message to send to stdin when the process has +                    launched +        :type msg: str +        """ +        self._msg = msg +        self._outBuffer = "" +        self._errBuffer = "" +        self._d = defer.Deferred() + +    @property +    def deferred(self): +        return self._d + +    def connectionMade(self): +        self.transport.write(self._msg) +        self.transport.closeStdin() + +    def outReceived(self, data): +        self._outBuffer += data + +    def errReceived(self, data): +        self._errBuffer += data + +    def processEnded(self, reason): +        if reason.check(ProcessDone): +            self._d.callback(self._outBuffer) +        else: +            self._d.errback(reason) + + +def _async_check_output(args, msg): +    """ +    Async spawn a process and return a defer to be able to check the +    output with a callback/errback + +    :param args: the command to execute along with the params for it +    :type args: list of str +    :param msg: string that will be send to stdin of the process once +                it's spawned +    :type msg: str + +    :rtype: defer.Deferred +    """ +    pprotocol = BouncerSubprocessProtocol(msg) +    reactor.spawnProcess(pprotocol, args[0], args) +    return pprotocol.deferred + + +class DSNGenerator(Generator): +    """ +    A slightly modified generator to correctly parse delivery status +    notifications. +    """ + +    def _handle_message_delivery_status(self, msg): +        """ +        Handle a message of type "message/delivery-status". + +        This is modified from upstream version in that it also removes empty +        lines in the beginning of each part. + +        :param msg: The message to be handled. +        :type msg: Message +        """ +        # We can't just write the headers directly to self's file object +        # because this will leave an extra newline between the last header +        # block and the boundary.  Sigh. +        blocks = [] +        for part in msg.get_payload(): +            s = StringIO() +            g = self.clone(s) +            g.flatten(part, unixfrom=False) +            text = s.getvalue() +            lines = text.split('\n') +            # Strip off the unnecessary trailing empty line +            if lines: +                if lines[0] == '': +                    lines.pop(0) +                if lines[-1] == '': +                    lines.pop() +                blocks.append(NL.join(lines)) +            else: +                blocks.append(text) +        # Now join all the blocks with an empty line.  This has the lovely +        # effect of separating each block with an empty line, but not adding +        # an extra one after the last one. +        self._fp.write(NL.join(blocks)) + + +class MIMEMultipartReport(MIMEMultipart): +    """ +    Implement multipart/report MIME type as defined in RFC 6522. + +    The syntax of multipart/report is identical to the multipart/mixed +    content type defined in https://tools.ietf.org/html/rfc2045. + +    The multipart/report media type contains either two or three sub- +    parts, in the following order: + +      1. (REQUIRED) A human-readable message. +      2. (REQUIRED) A machine-parsable body part containing an account of +         the reported message handling event. +      3. (OPTIONAL) A body part containing the returned message or a +         portion thereof. +    """ + +    def __init__( +            self, report_type="message/delivery-status", boundary=None, +            _subparts=None): +        """ +        Initialize the message. + +        As per RFC 6522, boundary and report_type are required parameters. + +        :param report_type: The type of report. This is set as a +                            "Content-Type" parameter, and should match the +                            MIME subtype of the second body part. +        :type report_type: str + +        """ +        MIMEMultipart.__init__( +            self, +            # set mime type to "multipart/report" +            _subtype="report", +            boundary=boundary, +            _subparts=_subparts, +            # add "report-type" as a "Content-Type" parameter +            report_type=report_type) +        self._report_type = report_type + +    def attach(self, payload): +        """ +        Add the given payload to the current payload, but first verify if it's +        valid according to RFC6522. + +        :param payload: The payload to be attached. +        :type payload: Message + +        :raise MessageError: Raised if the payload is invalid. +        """ +        idx = len(self.get_payload()) + 1 +        self._check_valid_payload(idx, payload) +        MIMEMultipart.attach(self, payload) + +    def _check_valid_payload(self, idx, payload): +        """ +        Check that an attachment is valid according to RFC6522. + +        :param payload: The payload to be attached. +        :type payload: Message + +        :raise MessageError: Raised if the payload is invalid. +        """ +        if idx == 1: +            # The text in the first section can use any IANA-registered MIME +            # media type, charset, or language. +            cond = lambda payload: isinstance(payload, MIMEBase) +            error_msg = "The first attachment must be a MIME message." +        elif idx == 2: +            # RFC 6522 requires that the report-type parameter is equal to the +            # MIME subtype of the second body type of the multipart/report. +            cond = lambda payload: \ +                payload.get_content_type() == self._report_type +            error_msg = "The second attachment's subtype must be %s." \ +                        % self._report_type +        elif idx == 3: +            # A body part containing the returned message or a portion thereof. +            cond = lambda payload: isinstance(payload, Message) +            error_msg = "The third attachment must be a message." +        else: +            # The multipart/report media type contains either two or three sub- +            # parts. +            cond = lambda _: False +            error_msg = "The multipart/report media type contains either " \ +                        "two or three sub-parts." +        if not cond(payload): +            raise MessageError("Invalid attachment: %s" % error_msg) + +    def as_string(self, unixfrom=False): +        """ +        Return the entire formatted message as string. + +        This is modified from upstream to use our own generator. + +        :param as_string: Whether to include the Unix From envelope heder. +        :type as_string: bool + +        :return: The entire formatted message. +        :rtype: str +        """ +        fp = StringIO() +        g = DSNGenerator(fp) +        g.flatten(self, unixfrom=unixfrom) +        return fp.getvalue() diff --git a/src/leap/mx/check_recipient_access.py b/src/leap/mx/check_recipient_access.py index b80ccfd..55460a6 100644 --- a/src/leap/mx/check_recipient_access.py +++ b/src/leap/mx/check_recipient_access.py @@ -17,26 +17,72 @@  # along with this program.  If not, see <http://www.gnu.org/licenses/>.  """ -Classes for resolving postfix recipient access +Classes for resolving postfix recipient access. + +The resolver is queried by the mail server before delivery to the mail spool +directory, and should check if the address is able to receive messages. +Examples of reasons for denying delivery would be that the user is out of +quota, is user, or have no pgp public key in the server.  Test this with postmap -v -q "foo" tcp:localhost:2244  """  from twisted.protocols import postfix -from leap.mx.alias_resolver import AliasResolverFactory +from leap.mx.tcp_map import LEAPPostfixTCPMapServerFactory +from leap.mx.tcp_map import TCP_MAP_CODE_SUCCESS +from leap.mx.tcp_map import TCP_MAP_CODE_TEMPORARY_FAILURE +from leap.mx.tcp_map import TCP_MAP_CODE_PERMANENT_FAILURE + + +class LEAPPostFixTCPMapAccessServer(postfix.PostfixTCPMapServer): +    """ +    A postfix tcp map recipient access checker server. +    The server potentially receives the uuid and a PGP key for the user, which +    are looked up by the factory, and will return a permanent or a temporary +    failure in case either the user or the key don't exist, respectivelly. +    """ -class LEAPPostFixTCPMapserverAccess(postfix.PostfixTCPMapServer):      def _cbGot(self, value): -        # For more info, see: -        # http://www.postfix.org/tcp_table.5.html -        # http://www.postfix.org/access.5.html -        if value is None: -            self.sendCode(500, postfix.quote("REJECT")) +        """ +        Return a code and message depending on the result of the factory's +        get(). + +        If there's no pgp public key for the user, we currently return a +        temporary failure saying that the user account is disabled. + +        For more info, see: http://www.postfix.org/access.5.html + +        :param value: The uuid and public key. +        :type value: list +        """ +        uuid, pubkey = value +        if uuid is None: +            self.sendCode( +                TCP_MAP_CODE_PERMANENT_FAILURE, +                postfix.quote("REJECT")) +        elif pubkey is None: +            self.sendCode( +                TCP_MAP_CODE_TEMPORARY_FAILURE, +                postfix.quote("4.7.13 USER ACCOUNT DISABLED"))          else: -            self.sendCode(200, postfix.quote("OK")) +            self.sendCode( +                TCP_MAP_CODE_SUCCESS, +                postfix.quote("OK")) + + +class CheckRecipientAccessFactory(LEAPPostfixTCPMapServerFactory): +    """ +    A factory for the recipient access checker. + +    When queried, the factory looks up the user's uuid and a PGP key for that +    user and returns the result to the server's _cbGot() method. +    """ + +    protocol = LEAPPostFixTCPMapAccessServer +    @property +    def _query_message(self): +        return "Checking recipient access for" -class CheckRecipientAccessFactory(AliasResolverFactory): -    protocol = LEAPPostFixTCPMapserverAccess diff --git a/src/leap/mx/couchdbhelper.py b/src/leap/mx/couchdbhelper.py index f20f1dd..1752b4e 100644 --- a/src/leap/mx/couchdbhelper.py +++ b/src/leap/mx/couchdbhelper.py @@ -15,24 +15,15 @@  # 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 working with CouchDB or BigCouch instances which store email alias  maps, user UUIDs, and GPG keyIDs.  """ -from functools import partial - -try: -    from paisley import client -except ImportError: -    print "This software requires paisley. Please see the README file" -    print "for instructions on getting required dependencies." -try: -    from twisted.python import log -except ImportError: -    print "This software requires Twisted. Please see the README file" -    print "for instructions on getting required dependencies." +from paisley import client +from twisted.python import log  class ConnectedCouchDB(client.CouchDB): @@ -66,24 +57,8 @@ class ConnectedCouchDB(client.CouchDB):                                  username=username,                                  password=password,                                  *args, **kwargs) -          self._cache = {} -        if dbName is None: -            databases = self.listDB() -            databases.addCallback(self._print_databases) - -    def _print_databases(self, data): -        """ -        Callback for listDB that prints the available databases - -        :param data: response from the listDB command -        :type data: array -        """ -        log.msg("Available databases:") -        for database in data: -            log.msg("  * %s" % (database,)) -      def createDB(self, dbName):          """          Overrides ``paisley.client.CouchDB.createDB``. @@ -96,110 +71,63 @@ class ConnectedCouchDB(client.CouchDB):          """          pass -    def queryByAddress(self, address): +    def getUuidAndPubkey(self, address):          """ -        Check to see if a particular email or alias exists. +        Query couch and return a deferred that will fire with the uuid and pgp +        public key for address. -        :param alias: A string representing the email or alias to check. -        :type alias: str -        :return: a deferred for this query +        :param address: A string representing the email or alias to check. +        :type address: str +        :return: A deferred that will fire with the user's uuid and pgp public +                 key.          :rtype twisted.defer.Deferred          """ -        assert isinstance(address, (str, unicode)), "Email or alias queries must be string" -          # TODO: Cache results -          d = self.openView(docId="Identity",                            viewId="by_address/",                            key=address,                            reduce=False,                            include_docs=True) -        d.addCallbacks(partial(self._get_uuid, address), log.err) - +        def _get_uuid_and_pubkey_cbk(result): +            uuid = None +            pubkey = None +            if result["rows"]: +                doc = result["rows"][0]["doc"] +                uuid = doc["user_id"] +                if "keys" in doc: +                    pubkey = doc["keys"]["pgp"] +            return uuid, pubkey + +        d.addCallback(_get_uuid_and_pubkey_cbk)          return d -    def _get_uuid(self, address, result): -        """ -        Parses the result of the by_address query and gets the uuid - -        :param address: alias looked up -        :type address: string -        :param result: result dictionary -        :type result: dict -        :return: The uuid for alias if available -        :rtype: str -        """ -        for row in result["rows"]: -            if row["key"] == address: -                uuid = row["doc"].get("user_id", None) -                if uuid is None: -                    log.msg("ERROR: Found doc for %s but there's not user_id!" -                            % (address,)) -                return uuid -        return None - -    def getPubKey(self, uuid): +    def getPubkey(self, uuid):          """ -        Returns a deferred that will return the pubkey for the uuid provided +        Query couch and return a deferred that will fire with the pgp public +        key for user with given uuid. -        :param uuid: uuid for the user to query +        :param uuid: The uuid of a user          :type uuid: str +        :return: A deferred that will fire with the pgp public key for +                 the user.          :rtype: Deferred          """          d = self.openView(docId="Identity", -                          viewId="pgp_key_by_email/", -                          user_id=uuid, +                          viewId="by_user_id/", +                          key=uuid,                            reduce=False,                            include_docs=True) -        d.addCallbacks(partial(self._get_pgp_key, uuid), log.err) +        def _get_pubkey_cbk(result): +            pubkey = None +            try: +                doc = result["rows"][0]["doc"] +                pubkey = doc["keys"]["pgp"] +            except (KeyError, IndexError): +                pass +            return pubkey +        d.addCallbacks(_get_pubkey_cbk, log.err)          return d - -    def _get_pgp_key(self, uuid, result): -        """ -        Callback used to filter the correct pubkey from the result of -        the query to the couchdb - -        :param uuid: uuid for the user that was queried -        :type uuid: str -        :param result: result dictionary for the db query -        :type result: dict - -        :rtype: str or None -        """ -        for row in result["rows"]: -            user_id = row["doc"].get("user_id") -            if not user_id: -                print("User %s is in an inconsistent state") -                continue -            if user_id == uuid: -                return row["value"] -        return None - -if __name__ == "__main__": -    from twisted.internet import reactor -    cdb = ConnectedCouchDB("localhost", -                           port=6666, -                           dbName="users", -                           username="", -                           password="") - -    d = cdb.queryByLoginOrAlias("test1") - -    @d.addCallback -    def right(result): -        print "Should be an actual uuid:", result -        print "Public Key:" -        print cdb.getPubKey(result) - -    d2 = cdb.queryByLoginOrAlias("asdjaoisdjoiqwjeoi") - -    @d2.addCallback -    def wrong(result): -        print "Should be None:", result - -    reactor.callLater(5, reactor.stop) -    reactor.run() diff --git a/src/leap/mx/mail_receiver.py b/src/leap/mx/mail_receiver.py index 630c982..446fd38 100644 --- a/src/leap/mx/mail_receiver.py +++ b/src/leap/mx/mail_receiver.py @@ -39,13 +39,8 @@ import signal  import json  import email.utils -import socket  from email import message_from_string -from email.MIMEMultipart import MIMEMultipart -from email.MIMEText import MIMEText -from email.Utils import formatdate -from email.header import decode_header  from twisted.application.service import Service, IService  from twisted.internet import inotify, defer, task, reactor @@ -53,84 +48,15 @@ from twisted.python import filepath, log  from zope.interface import implements -from leap.soledad.common.crypto import ( -    EncryptionSchemes, -    ENC_JSON_KEY, -    ENC_SCHEME_KEY, -) +from leap.soledad.common.crypto import EncryptionSchemes +from leap.soledad.common.crypto import ENC_JSON_KEY +from leap.soledad.common.crypto import ENC_SCHEME_KEY  from leap.soledad.common.couch import CouchDatabase, CouchDocument -from leap.keymanager import openpgp - -BOUNCE_TEMPLATE = """ -Delivery to the following recipient failed: -    {0} - -Reasons: -    {1} - -Original message: -    {2} -""".strip() - - -from twisted.internet import protocol -from twisted.internet.error import ProcessDone - - -class BouncerSubprocessProtocol(protocol.ProcessProtocol): -    """ -    Bouncer subprocess protocol that will feed the msg contents to be -    bounced through stdin -    """ - -    def __init__(self, msg): -        """ -        Constructor for the BouncerSubprocessProtocol - -        :param msg: Message to send to stdin when the process has -                    launched -        :type msg: str -        """ -        self._msg = msg -        self._outBuffer = "" -        self._errBuffer = "" -        self._d = None - -    def connectionMade(self): -        self._d = defer.Deferred() - -        self.transport.write(self._msg) -        self.transport.closeStdin() - -    def outReceived(self, data): -        self._outBuffer += data -    def errReceived(self, data): -        self._errBuffer += data - -    def processEnded(self, reason): -        if reason.check(ProcessDone): -            self._d.callback(self._outBuffer) -        else: -            self._d.errback(reason) - - -def async_check_output(args, msg): -    """ -    Async spawn a process and return a defer to be able to check the -    output with a callback/errback - -    :param args: the command to execute along with the params for it -    :type args: list of str -    :param msg: string that will be send to stdin of the process once -                it's spawned -    :type msg: str +from leap.keymanager import openpgp -    :rtype: defer.Deferred -    """ -    pprotocol = BouncerSubprocessProtocol(msg) -    reactor.spawnProcess(pprotocol, args[0], args) -    return pprotocol.d +from leap.mx.bounce import bounce_message +from leap.mx.bounce import InvalidReturnPathError  class MailReceiver(Service): @@ -177,8 +103,6 @@ class MailReceiver(Service):          self._directories = directories          self._bounce_from = bounce_from          self._bounce_subject = bounce_subject - -        self._domain = socket.gethostbyaddr(socket.gethostname())[0]          self._processing_skipped = False      def startService(self): @@ -353,7 +277,7 @@ class MailReceiver(Service):      def _get_owner(self, mail):          """ -        Given an email, returns the uuid of the owner. +        Given an email, return the uuid of the owner.          :param mail: mail to analyze          :type mail: email.message.Message @@ -361,25 +285,24 @@ class MailReceiver(Service):          :returns: uuid          :rtype: str or None          """ -        uuid = None - +        # we expect the topmost "Delivered-To" header to indicate the correct +        # final delivery address. It should consist of <uuid>@<domain>, as the +        # earlier alias resolver query should have translated the username to +        # the user id. See https://leap.se/code/issues/6858 for more info.          delivereds = mail.get_all("Delivered-To")          if delivereds is None: +            # XXX this should not happen! see the comment above              return None -        for to in delivereds: -            name, addr = email.utils.parseaddr(to) -            parts = addr.split("@") -            if len(parts) > 1 and parts[1] == self._domain: -                uuid = parts[0] -                break - +        final_address = delivereds.pop(0) +        _, addr = email.utils.parseaddr(final_address) +        uuid, _ = addr.split("@")          return uuid      @defer.inlineCallbacks -    def _bounce_mail(self, orig_msg, filepath, reason): +    def _bounce_message(self, orig_msg, filepath, reason):          """ -        Bounces the email contained in orig_msg to it's sender and -        removes it from the queue. +        Bounce the message contained in orig_msg to it's sender and +        remove it from the queue.          :param orig_msg: Message that is going to be bounced          :type orig_msg: email.message.Message @@ -388,22 +311,12 @@ class MailReceiver(Service):          :param reason: Brief explanation about why it's being bounced          :type reason: str          """ -        to = orig_msg.get("From") - -        msg = MIMEMultipart() -        msg['From'] = self._bounce_from -        msg['To'] = to -        msg['Date'] = formatdate(localtime=True) -        msg['Subject'] = self._bounce_subject - -        decoded_to = " ".join([x[0] for x in decode_header(to)]) -        text = BOUNCE_TEMPLATE.format(decoded_to, -                                      reason, -                                      orig_msg.as_string()) - -        msg.attach(MIMEText(text)) - -        yield async_check_output(["/usr/sbin/sendmail", "-t"], msg.as_string()) +        try: +            yield bounce_message( +                self._bounce_from, self._bounce_subject, orig_msg, reason) +        except InvalidReturnPathError: +            # give up bouncing this message! +            log.msg("Will not bounce message because of invalid return path.")          yield self._conditional_remove(True, filepath)      def sleep(self, secs): @@ -478,7 +391,7 @@ class MailReceiver(Service):                          (filepath.path,))                  bounce_reason = "Missing UUID: There was a problem " \                                  "locating the user in our database." -                yield self._bounce_mail(msg, filepath, bounce_reason) +                yield self._bounce_message(msg, filepath, bounce_reason)                  defer.returnValue(None)              log.msg("Mail owner: %s" % (uuid,)) @@ -486,13 +399,15 @@ class MailReceiver(Service):                  log.msg("BUG: There was no uuid!")                  defer.returnValue(None) -            pubkey = yield self._users_cdb.getPubKey(uuid) +            pubkey = yield self._users_cdb.getPubkey(uuid)              if pubkey is None or len(pubkey) == 0: -                log.msg("No public key, stopping the processing chain") -                bounce_reason = "Missing PubKey: There was a problem " \ -                                "locating the user's public key in our " \ -                                "database." -                yield self._bounce_mail(msg, filepath, bounce_reason) +                log.msg( +                    "No public key for %s, stopping the processing chain." +                    % uuid) +                bounce_reason = "Missing PGP public key: There was a " \ +                                "problem locating the user's public key in " \ +                                "our database." +                yield self._bounce_message(msg, filepath, bounce_reason)                  defer.returnValue(None)              log.msg("Encrypting message to %s's pubkey" % (uuid,)) diff --git a/src/leap/mx/tcp_map.py b/src/leap/mx/tcp_map.py new file mode 100644 index 0000000..597c830 --- /dev/null +++ b/src/leap/mx/tcp_map.py @@ -0,0 +1,72 @@ +#!/usr/bin/env python +# -*- encoding: utf-8 -*- +# tcpmap.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/>. + + +from abc import ABCMeta +from abc import abstractproperty + +from twisted.internet.protocol import ServerFactory +from twisted.python import log + + +# For info on codes, see: http://www.postfix.org/tcp_table.5.html +TCP_MAP_CODE_SUCCESS = 200 +TCP_MAP_CODE_TEMPORARY_FAILURE = 400 +TCP_MAP_CODE_PERMANENT_FAILURE = 500 + + +# we have to also extend from object here to make the class a new-style class. +# If we don't, we get a TypeError because "new-style classes can't have only +# classic bases". This has to do with the way abc.ABCMeta works and the old +# and new style of python classes. +class LEAPPostfixTCPMapServerFactory(ServerFactory, object): +    """ +    A factory for postfix tcp map servers. +    """ + +    __metaclass__ = ABCMeta + + +    def __init__(self, couchdb): +        """ +        Initialize the factory. + +        :param couchdb: A CouchDB client. +        :type couchdb: leap.mx.couchdbhelper.ConnectedCouchDB +        """ +        self._cdb = couchdb + +    @abstractproperty +    def _query_message(self): +        pass + +    def get(self, lookup_key): +        """ +        Look up user based on lookup_key. + +        :param lookup_key: The lookup key. +        :type lookup_key: str + +        :return: A deferred that will be fired with the user's address, uuid +                 and pgp key. +        :rtype: Deferred +        """ +        log.msg("%s %s" % (self._query_message, lookup_key,)) +        d = self._cdb.getUuidAndPubkey(lookup_key) +        d.addErrback(log.err) +        return d | 
