summaryrefslogtreecommitdiff
path: root/src/leap/mx
diff options
context:
space:
mode:
authordrebs <drebs@leap.se>2015-04-29 15:46:29 -0300
committerdrebs <drebs@leap.se>2015-04-29 15:46:29 -0300
commit76937040c20dd786825261e6ee2fc3766ef3d8e0 (patch)
treeb4cf68efe89d4dd5bdf4362a388be6b51c403a9d /src/leap/mx
parentb5ed3e4db16c8e3856b5c45409807d8f58cd957a (diff)
parent799703cf884191d097eb5d5316fa964e421683fd (diff)
Merge tag '0.6.2'
Tag leap.mx version 0.6.2
Diffstat (limited to 'src/leap/mx')
-rw-r--r--src/leap/mx/alias_resolver.py91
-rw-r--r--src/leap/mx/bounce.py526
-rw-r--r--src/leap/mx/check_recipient_access.py68
-rw-r--r--src/leap/mx/couchdbhelper.py148
-rw-r--r--src/leap/mx/mail_receiver.py151
-rw-r--r--src/leap/mx/tcp_map.py72
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