diff options
| author | J08nY | 2017-03-13 02:14:26 +0100 |
|---|---|---|
| committer | J08nY | 2017-07-24 18:44:19 +0200 |
| commit | ad463598885ecc91bee9fbca5aea9fcb4e9f627e (patch) | |
| tree | 9363afa79afaf75c32742c57b6e1cee8754d7a32 /src/mailman/mta | |
| parent | 02826321d0430d7ffc1f674eeff4221941689ef7 (diff) | |
| download | mailman-mta-smtps-starttls.tar.gz mailman-mta-smtps-starttls.tar.zst mailman-mta-smtps-starttls.zip | |
Diffstat (limited to 'src/mailman/mta')
| -rw-r--r-- | src/mailman/mta/base.py | 31 | ||||
| -rw-r--r-- | src/mailman/mta/connection.py | 103 | ||||
| -rw-r--r-- | src/mailman/mta/docs/connection.rst | 29 | ||||
| -rw-r--r-- | src/mailman/mta/tests/test_base.py | 71 | ||||
| -rw-r--r-- | src/mailman/mta/tests/test_connection.py | 89 |
5 files changed, 282 insertions, 41 deletions
diff --git a/src/mailman/mta/base.py b/src/mailman/mta/base.py index 4cc7742e8..b182da027 100644 --- a/src/mailman/mta/base.py +++ b/src/mailman/mta/base.py @@ -22,9 +22,12 @@ import socket import logging import smtplib +from lazr.config import as_boolean from mailman.config import config +from mailman.interfaces.configuration import InvalidConfigurationError from mailman.interfaces.mta import IMailTransportAgentDelivery -from mailman.mta.connection import Connection +from mailman.mta.connection import ( + SMTPConnection, SMTPSConnection, STARTTLSConnection) from public import public from zope.interface import implementer @@ -39,12 +42,30 @@ class BaseDelivery: def __init__(self): """Create a basic deliverer.""" + host = config.mta.smtp_host + port = int(config.mta.smtp_port) username = (config.mta.smtp_user if config.mta.smtp_user else None) password = (config.mta.smtp_pass if config.mta.smtp_pass else None) - self._connection = Connection( - config.mta.smtp_host, int(config.mta.smtp_port), - int(config.mta.max_sessions_per_connection), - username, password) + protocol = config.mta.smtp_protocol + verify_cert = as_boolean(config.mta.smtp_verify_cert) + verify_hostname = as_boolean(config.mta.smtp_verify_hostname) + max_session_count = int(config.mta.max_sessions_per_connection) + + proto = protocol.lower() + if proto == 'smtp': + self._connection = SMTPConnection(host, port, max_session_count, + username, password) + elif proto == 'smtps': + self._connection = SMTPSConnection(host, port, max_session_count, + verify_cert, verify_hostname, + username, password) + elif proto == 'starttls': + self._connection = STARTTLSConnection(host, port, + max_session_count, + verify_cert, verify_hostname, + username, password) + else: + raise InvalidConfigurationError('mta.smtp_protocol', protocol) def _deliver_to_recipients(self, mlist, msg, msgdata, recipients): """Low-level delivery to a set of recipients. diff --git a/src/mailman/mta/connection.py b/src/mailman/mta/connection.py index 797b979b2..d6824bccf 100644 --- a/src/mailman/mta/connection.py +++ b/src/mailman/mta/connection.py @@ -17,21 +17,23 @@ """MTA connections.""" +import ssl import logging import smtplib from contextlib import suppress from lazr.config import as_boolean from mailman.config import config +from mailman.interfaces.smtp import ISMTPConnection from public import public - +from zope.interface import implementer log = logging.getLogger('mailman.smtp') -@public +@implementer(ISMTPConnection) class Connection: - """Manage a connection to the SMTP server.""" + def __init__(self, host, port, sessions_per_connection, smtp_user=None, smtp_pass=None): """Create a connection manager. @@ -60,24 +62,20 @@ class Connection: self._session_count = None self._connection = None - def _connect(self): - """Open a new connection.""" - self._connection = smtplib.SMTP() - log.debug('Connecting to %s:%s', self._host, self._port) - self._connection.connect(self._host, self._port) + def _login(self): + """Send login if both username and password are specified.""" if self._username is not None and self._password is not None: log.debug('Logging in') self._connection.login(self._username, self._password) - self._session_count = self._sessions_per_connection def sendmail(self, envsender, recipients, msgtext): - """Mimic `smtplib.SMTP.sendmail`.""" if as_boolean(config.devmode.enabled): # Force the recipients to the specified address, but still deliver # to the same number of recipients. recipients = [config.devmode.recipient] * len(recipients) if self._connection is None: self._connect() + self._login() try: log.debug('envsender: %s, recipients: %s, size(msgtext): %s', envsender, recipients, len(msgtext)) @@ -97,9 +95,92 @@ class Connection: return results def quit(self): - """Mimic `smtplib.SMTP.quit`.""" if self._connection is None: return with suppress(smtplib.SMTPException): self._connection.quit() self._connection = None + + +@public +class SMTPConnection(Connection): + """Manage a clear connection to the SMTP server.""" + + def _connect(self): + """Open a new connection.""" + log.debug('Connecting to %s:%s', self._host, self._port) + self._connection = smtplib.SMTP(self._host, self._port) + self._session_count = self._sessions_per_connection + + +class _SSLConnection(Connection): + """Manage a SMTP connection with a SSL context(either SMTPS or STARTTLS)""" + + def __init__(self, host, port, sessions_per_connection, + verify_cert=False, verify_hostname=False, + smtp_user=None, smtp_pass=None): + """ + Create a connection manager with and SSL context. + + :param host: The host name of the SMTP server to connect to. + :type host: string + :param port: The port number of the SMTP server to connect to. + :type port: integer + :param sessions_per_connection: The number of SMTP sessions per + connection to the SMTP server. After this number of sessions + has been reached, the connection is closed and a new one is + opened. Set to zero for an unlimited number of sessions per + connection (i.e. your MTA has no limit). + :type sessions_per_connection: integer + :param verify_cert: Whether to require a server cert and verify it. + Verification in this context means that the server needs to supply + a valid certificate signed by a CA from a set of the system's + default CA certs. + :type verify_cert: bool + :param verify_hostname: Whether to check that the server certificate + specifies the hostname as passed to this constructor. + RFC 2818 and RFC 6125 rules are followed. + :type verify_hostname: bool + :param smtp_user: Optional SMTP authentication user name. If given, + `smtp_pass` must also be given. + :type smtp_user: str + :param smtp_pass: Optional SMTP authentication password. If given, + `smtp_user` must also be given. + """ + super().__init__(host, port, sessions_per_connection, + smtp_user, smtp_pass) + self._tls_context = self._get_context(verify_cert, verify_hostname) + + def _get_context(self, verify_cert, verify_hostname): + """Create and return a new SSLContext.""" + ssl_context = ssl.create_default_context() + ssl_context.check_hostname = verify_hostname + if verify_cert: + ssl_context.verify_mode = ssl.CERT_REQUIRED + else: + ssl_context.verify_mode = ssl.CERT_NONE + return ssl_context + + +@public +class SMTPSConnection(_SSLConnection): + """Manage a SMTPS connection.""" + + def _connect(self): + log.debug('Connecting to %s:%s', self._host, self._port) + self._connection = smtplib.SMTP_SSL(self._host, self._port, + context=self._tls_context) + self._session_count = self._sessions_per_connection + + +@public +class STARTTLSConnection(_SSLConnection, SMTPConnection): + """Manage a plain connection with STARTTLS.""" + + def _connect(self): + super()._connect() + log.debug('Starttls') + try: + self._connection.starttls(context=self._tls_context) + except smtplib.SMTPNotSupportedError as notls: + log.error('Starttls failed: ' + str(notls)) diff --git a/src/mailman/mta/docs/connection.rst b/src/mailman/mta/docs/connection.rst index 8b11605cb..687ec06fb 100644 --- a/src/mailman/mta/docs/connection.rst +++ b/src/mailman/mta/docs/connection.rst @@ -3,16 +3,17 @@ MTA connections =============== Outgoing connections to the outgoing mail transport agent (MTA) are mitigated -through a ``Connection`` class, which can transparently manage multiple +through a ``SMTPConnection`` class, which can transparently manage multiple sessions in a single connection. - >>> from mailman.mta.connection import Connection + >>> from mailman.mta.connection import SMTPConnection, SMTPSConnection + >>> from lazr.config import as_boolean -The number of sessions per connections is specified when the ``Connection`` +The number of sessions per connections is specified when the ``SMTPConnection`` object is created, as is the host and port number of the SMTP server. Zero means there's an unlimited number of sessions per connection. - >>> connection = Connection( + >>> connection = SMTPConnection( ... config.mta.smtp_host, int(config.mta.smtp_port), 0) At the start, there have been no connections to the server. @@ -59,7 +60,7 @@ will authenticate with the mail server after each new connection. ... smtp_pass: testpass ... """) - >>> connection = Connection( + >>> connection = SMTPConnection( ... config.mta.smtp_host, int(config.mta.smtp_port), 0, ... config.mta.smtp_user, config.mta.smtp_pass) >>> connection.sendmail('anne@example.com', ['bart@example.com'], """\ @@ -75,6 +76,17 @@ will authenticate with the mail server after each new connection. >>> reset() >>> config.pop('auth') +SMTPS and STARTTLS support works through ``SMTPSConnection`` +and ``STARTTLSConnection`` classes. These provide the same interface +as ``SMTPConnection``. +:: + + >>> connection = SMTPSConnection( + ... config.mta.smtp_host, int(config.mta.smtp_port), 0, + ... as_boolean(config.mta.smtp_verify_cert), + ... as_boolean(config.mta.smtp_verify_hostname), + ... config.mta.smtp_user, config.mta.smtp_pass) + Sessions per connection ======================= @@ -86,13 +98,13 @@ created. The connection count starts at zero. :: - >>> connection = Connection( + >>> connection = SMTPConnection( ... config.mta.smtp_host, int(config.mta.smtp_port), 2) >>> smtpd.get_connection_count() 0 -We send two messages through the ``Connection`` object. Only one connection +We send two messages through the ``SMTPConnection`` object. Only one connection is opened. :: @@ -168,7 +180,7 @@ No maximum A value of zero means that there is an unlimited number of sessions per connection. - >>> connection = Connection( + >>> connection = SMTPConnection( ... config.mta.smtp_host, int(config.mta.smtp_port), 0) >>> reset() @@ -230,3 +242,4 @@ about accidental deliveries to unintended recipients. <BLANKLINE> >>> config.pop('devmode') + diff --git a/src/mailman/mta/tests/test_base.py b/src/mailman/mta/tests/test_base.py new file mode 100644 index 000000000..52fa63430 --- /dev/null +++ b/src/mailman/mta/tests/test_base.py @@ -0,0 +1,71 @@ +# Copyright (C) 2014-2017 by the Free Software Foundation, Inc. +# +# This file is part of GNU Mailman. +# +# GNU Mailman 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. +# +# GNU Mailman 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 +# GNU Mailman. If not, see <http://www.gnu.org/licenses/>. + +"""Test BaseDelivery.""" + +import unittest + +from mailman.config import config +from mailman.interfaces.configuration import InvalidConfigurationError +from mailman.mta.base import BaseDelivery +from mailman.mta.connection import SMTPSConnection, STARTTLSConnection +from mailman.testing.layers import SMTPLayer, SMTPSLayer, STARTTLSLayer + + +class BaseDeliveryTester(BaseDelivery): + @property + def connection(self): + return self._connection + + +class TestSMTPSDelivery(unittest.TestCase): + layer = SMTPSLayer + + def test_smtps_config(self): + config.push('smtps_config', """\ +[mta] +smtp_protocol: smtps +""") + delivery = BaseDeliveryTester() + self.assertIsInstance(delivery.connection, SMTPSConnection) + config.pop('smtps_config') + + +class TestSTARTTLSDelivery(unittest.TestCase): + layer = STARTTLSLayer + + def test_starttls_config(self): + config.push('starttls_config', """\ +[mta] +smtp_protocol: starttls +""") + delivery = BaseDeliveryTester() + self.assertIsInstance(delivery.connection, STARTTLSConnection) + config.pop('starttls_config') + + +class TestInvalidDelivery(unittest.TestCase): + layer = SMTPLayer + + def test_invalid_config(self): + config.push('invalid_config', """\ +[mta] +smtp_protocol: invalid +""") + with self.assertRaises(InvalidConfigurationError): + BaseDeliveryTester() + config.pop('invalid_config') diff --git a/src/mailman/mta/tests/test_connection.py b/src/mailman/mta/tests/test_connection.py index b742f7abc..566e19daa 100644 --- a/src/mailman/mta/tests/test_connection.py +++ b/src/mailman/mta/tests/test_connection.py @@ -20,9 +20,11 @@ import unittest from mailman.config import config -from mailman.mta.connection import Connection -from mailman.testing.layers import SMTPLayer -from smtplib import SMTP, SMTPAuthenticationError +from mailman.mta.connection import ( + SMTPConnection, SMTPSConnection, STARTTLSConnection) +from mailman.testing.helpers import LogFileMark +from mailman.testing.layers import SMTPLayer, SMTPSLayer, STARTTLSLayer +from smtplib import SMTPAuthenticationError class TestConnection(unittest.TestCase): @@ -32,7 +34,7 @@ class TestConnection(unittest.TestCase): # Logging in to the MTA with a bad user name and password produces a # 571 Bad Authentication error. with self.assertRaises(SMTPAuthenticationError) as cm: - connection = Connection( + connection = SMTPConnection( config.mta.smtp_host, int(config.mta.smtp_port), 0, 'baduser', 'badpass') connection.sendmail('anne@example.com', ['bart@example.com'], """\ @@ -46,7 +48,7 @@ Subject: aardvarks def test_authentication_good_path(self): # Logging in with the correct user name and password succeeds. - connection = Connection( + connection = SMTPConnection( config.mta.smtp_host, int(config.mta.smtp_port), 0, 'testuser', 'testpass') connection.sendmail('anne@example.com', ['bart@example.com'], """\ @@ -59,27 +61,31 @@ Subject: aardvarks 'AHRlc3R1c2VyAHRlc3RwYXNz') -class TestConnectionCount(unittest.TestCase): - layer = SMTPLayer +class _ConnectionCounter: + layer = None def setUp(self): - self.connection = Connection( - config.mta.smtp_host, int(config.mta.smtp_port), 0) + self.connection = None self.msg_text = """\ From: anne@example.com To: bart@example.com Subject: aardvarks - """ def test_count_0(self): # So far, no connections. - self.assertEqual(SMTPLayer.smtpd.get_connection_count(), 0) + unittest.TestCase.assertEqual(self, + self.layer.smtpd.get_connection_count(), + 0) + pass def test_count_1(self): self.connection.sendmail( 'anne@example.com', ['bart@example.com'], self.msg_text) - self.assertEqual(SMTPLayer.smtpd.get_connection_count(), 1) + unittest.TestCase.assertEqual(self, + self.layer.smtpd.get_connection_count(), + 1) + pass def test_count_2(self): self.connection.sendmail( @@ -88,7 +94,10 @@ Subject: aardvarks self.connection.sendmail( 'cate@example.com', ['dave@example.com'], self.msg_text) self.connection.quit() - self.assertEqual(SMTPLayer.smtpd.get_connection_count(), 2) + unittest.TestCase.assertEqual(self, + self.layer.smtpd.get_connection_count(), + 2) + pass def test_count_reset(self): self.connection.sendmail( @@ -98,7 +107,53 @@ Subject: aardvarks 'cate@example.com', ['dave@example.com'], self.msg_text) self.connection.quit() # Issue the fake SMTP command to reset the count. - client = SMTP() - client.connect(config.mta.smtp_host, int(config.mta.smtp_port)) - client.docmd('RSET') - self.assertEqual(SMTPLayer.smtpd.get_connection_count(), 0) + self.layer.smtpd.reset() + unittest.TestCase.assertEqual(self, + self.layer.smtpd.get_connection_count(), + 0) + + +class TestSMTPConnectionCount(_ConnectionCounter, unittest.TestCase): + layer = SMTPLayer + + def setUp(self): + super().setUp() + self.connection = SMTPConnection( + config.mta.smtp_host, int(config.mta.smtp_port), 0) + + +class TestSMTPSConnectionCount(_ConnectionCounter, unittest.TestCase): + layer = SMTPSLayer + + def setUp(self): + super().setUp() + self.connection = SMTPSConnection( + config.mta.smtp_host, int(config.mta.smtp_port), 0) + + +class TestSTARTTLSConnectionCount(_ConnectionCounter, unittest.TestCase): + layer = STARTTLSLayer + + def setUp(self): + super().setUp() + self.connection = STARTTLSConnection( + config.mta.smtp_host, int(config.mta.smtp_port), 0) + + +class TestSTARTTLSNotSupported(unittest.TestCase): + layer = SMTPLayer + + def test_not_supported(self): + connection = STARTTLSConnection( + config.mta.smtp_host, int(config.mta.smtp_port), 0) + msg_text = """\ +From: anne@example.com +To: bart@example.com +Subject: aardvarks +""" + smtp_log = LogFileMark('mailman.smtp') + connection.sendmail( + 'anne@example.com', ['bart@example.com'], msg_text) + lines = smtp_log.read() + self.assertIn('Starttls failed', lines) + connection.quit() |
