From ad463598885ecc91bee9fbca5aea9fcb4e9f627e Mon Sep 17 00:00:00 2001 From: J08nY Date: Mon, 13 Mar 2017 02:14:26 +0100 Subject: Add SMTPS/STARTTLS support. - Adds SMTP over SSL/TLS and STARTTLS support to the outgoing delivery by spliting the Connection class into three separate classes with logic for SMTP, SMTP over SSL/TLS and STARTTLS. - The Delivery class loads the added configuratio values and selects the appropriate connection based on the configured protocol. - This requires aiosmtpd at least 1.1a1 for tests to pass, as SMTPS support was added there in: https://github.com/aio-libs/aiosmtpd/commit/0d33554c4787e575b7dd31b574259c0876dcc9f8 --- src/mailman/config/schema.cfg | 16 +++++ src/mailman/interfaces/configuration.py | 9 +++ src/mailman/interfaces/smtp.py | 32 +++++++++ src/mailman/mta/base.py | 31 ++++++-- src/mailman/mta/connection.py | 103 ++++++++++++++++++++++++--- src/mailman/mta/docs/connection.rst | 29 +++++--- src/mailman/mta/tests/test_base.py | 71 +++++++++++++++++++ src/mailman/mta/tests/test_connection.py | 89 ++++++++++++++++++----- src/mailman/testing/layers.py | 118 ++++++++++++++++++++++++++++++- src/mailman/testing/mta.py | 53 ++++++++++++-- src/mailman/testing/ssl_test_cert.crt | 21 ++++++ src/mailman/testing/ssl_test_key.key | 28 ++++++++ 12 files changed, 552 insertions(+), 48 deletions(-) create mode 100644 src/mailman/interfaces/smtp.py create mode 100644 src/mailman/mta/tests/test_base.py create mode 100644 src/mailman/testing/ssl_test_cert.crt create mode 100644 src/mailman/testing/ssl_test_key.key diff --git a/src/mailman/config/schema.cfg b/src/mailman/config/schema.cfg index 9568694be..35ff2f3ea 100644 --- a/src/mailman/config/schema.cfg +++ b/src/mailman/config/schema.cfg @@ -596,6 +596,22 @@ smtp_port: 25 smtp_user: smtp_pass: +# One of smtp/smtps/starttls, specifies the protocol Mailman will use when +# connecting. The difference between smtps and startls is that with smtps the +# connection starts with the SSL/TLS handshake, while starttls is an extension +# of the SMTP protocol which starts the SSL/TLS handshake after the client +# requests it. +smtp_protocol: smtp + +# 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. +smtp_verify_cert: yes + +# Whether to check that the server certificate specifies the hostname as +# in smtp_host. RFC 2818 and RFC 6125 rules are followed. +smtp_verify_hostname: yes + # Where the LMTP server listens for connections. Use 127.0.0.1 instead of # localhost for Postfix integration, because Postfix only consults DNS # (e.g. not /etc/hosts). diff --git a/src/mailman/interfaces/configuration.py b/src/mailman/interfaces/configuration.py index 2a9369fd1..7cee9972a 100644 --- a/src/mailman/interfaces/configuration.py +++ b/src/mailman/interfaces/configuration.py @@ -30,6 +30,15 @@ class MissingConfigurationFileError(MailmanError): self.path = path +@public +class InvalidConfigurationError(MailmanError): + """A configuration key had an unexpected/invalid value.""" + + def __init__(self, key, value): + self.key = key + self.value = value + + @public class IConfiguration(Interface): """Marker interface; used for adaptation in the REST API.""" diff --git a/src/mailman/interfaces/smtp.py b/src/mailman/interfaces/smtp.py new file mode 100644 index 000000000..477152621 --- /dev/null +++ b/src/mailman/interfaces/smtp.py @@ -0,0 +1,32 @@ +# Copyright (C) 2009-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 . + +"""Interface for a SMTP connection.""" + +from public import public +from zope.interface import Interface + + +@public +class ISMTPConnection(Interface): + """A SMTP connection. Like smtplib.SMTP.""" + + def sendmail(self, envsender, recipients, msgtext): + """Mimic `smtplib.SMTP.sendmail`.""" + + def quit(self): + """Mimic `smtplib.SMTP.quit`.""" 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. >>> 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 . + +"""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() diff --git a/src/mailman/testing/layers.py b/src/mailman/testing/layers.py index dbed1fccf..54ba4f5a1 100644 --- a/src/mailman/testing/layers.py +++ b/src/mailman/testing/layers.py @@ -25,6 +25,7 @@ # get rid of the layers and use something like testresources or some such. import os +import ssl import sys import shutil import logging @@ -40,14 +41,15 @@ from mailman.database.transaction import transaction from mailman.interfaces.domain import IDomainManager from mailman.testing.helpers import ( TestableMaster, get_lmtp_client, reset_the_world, wait_for_webservice) -from mailman.testing.mta import ConnectionCountingController +from mailman.testing.mta import ( + ConnectionCountingController, ConnectionCountingSSLController, + ConnectionCountingSTARTLSController) from mailman.utilities.string import expand -from pkg_resources import resource_string as resource_bytes +from pkg_resources import resource_filename, resource_string as resource_bytes from public import public from textwrap import dedent from zope.component import getUtility - TEST_TIMEOUT = datetime.timedelta(seconds=5) NL = '\n' @@ -258,6 +260,116 @@ class SMTPLayer(ConfigLayer): cls.smtpd.clear() +@public +class SMTPSLayer(ConfigLayer): + """Layer for starting, stopping, and accessing a test SMTPS server.""" + + smtpd = None + + @classmethod + def setUp(cls): + assert cls.smtpd is None, 'Layer already set up' + # Use a different port than the SMTP layer, since that one might + # still be in use. + config.push('smtps', """ + [mta] + smtp_port: 9026 + smtp_protocol: smtps + """) + test_cert_path = resource_filename('mailman.testing', + 'ssl_test_cert.crt') + test_key_path = resource_filename('mailman.testing', + 'ssl_test_key.key') + + client_context = ssl.create_default_context() + client_context.load_verify_locations(cafile=test_cert_path) + + server_context = ssl.create_default_context(ssl.Purpose.CLIENT_AUTH) + server_context.load_cert_chain(test_cert_path, test_key_path) + + host = config.mta.smtp_host + port = int(config.mta.smtp_port) + + cls.smtpd = ConnectionCountingSSLController( + host, port, + client_context=client_context, + server_context=server_context) + cls.smtpd.start() + + @classmethod + def testSetUp(cls): + # Make sure we don't call our superclass's testSetUp(), otherwise the + # example.com domain will get added twice. + pass + + @classmethod + def testTearDown(cls): + cls.smtpd.reset() + cls.smtpd.clear() + + @classmethod + def tearDown(cls): + assert cls.smtpd is not None, 'Layer not set up' + cls.smtpd.clear() + cls.smtpd.stop() + config.pop('smtps') + + +@public +class STARTTLSLayer(ConfigLayer): + """Layer for starting and stopping a test SMTP server with STARTTLS.""" + + smtpd = None + + @classmethod + def setUp(cls): + assert cls.smtpd is None, 'Layer already set up' + # Use a different port than the SMTP and SMTPS layers, since that one + # might still be in use. + config.push('starttls', """ + [mta] + smtp_port: 9027 + smtp_protocol: starttls + """) + test_cert_path = resource_filename('mailman.testing', + 'ssl_test_cert.crt') + test_key_path = resource_filename('mailman.testing', + 'ssl_test_key.key') + + client_context = ssl.create_default_context() + client_context.load_verify_locations(cafile=test_cert_path) + + server_context = ssl.create_default_context(ssl.Purpose.CLIENT_AUTH) + server_context.load_cert_chain(test_cert_path, test_key_path) + + host = config.mta.smtp_host + port = int(config.mta.smtp_port) + + cls.smtpd = ConnectionCountingSTARTLSController( + host, port, + client_context=client_context, + server_context=server_context) + cls.smtpd.start() + + @classmethod + def testSetUp(cls): + # Make sure we don't call our superclass's testSetUp(), otherwise the + # example.com domain will get added twice. + pass + + @classmethod + def testTearDown(cls): + cls.smtpd.reset() + cls.smtpd.clear() + + @classmethod + def tearDown(cls): + assert cls.smtpd is not None, 'Layer not set up' + cls.smtpd.clear() + cls.smtpd.stop() + config.pop('starttls') + + @public class LMTPLayer(ConfigLayer): """Layer for starting, stopping, and accessing a test LMTP server.""" diff --git a/src/mailman/testing/mta.py b/src/mailman/testing/mta.py index ef4d39233..02a3b83d2 100644 --- a/src/mailman/testing/mta.py +++ b/src/mailman/testing/mta.py @@ -166,12 +166,14 @@ class ConnectionCountingSMTP(SMTP): class ConnectionCountingController(Controller): """Count the number of SMTP connections opened.""" - def __init__(self, host, port): + def __init__(self, host, port, ssl_context=None): self._msg_queue = Queue() self._oob_queue = Queue() self.err_queue = Queue() + handler = ConnectionCountingHandler(self._msg_queue) - super().__init__(handler, hostname=host, port=port) + super().__init__(handler, hostname=host, port=port, + ssl_context=ssl_context) def factory(self): return ConnectionCountingSMTP( @@ -184,8 +186,7 @@ class ConnectionCountingController(Controller): self.reset() def _connect(self): - client = smtplib.SMTP() - client.connect(self.hostname, self.port) + client = smtplib.SMTP(self.hostname, self.port) return client def get_connection_count(self): @@ -223,3 +224,47 @@ class ConnectionCountingController(Controller): def reset(self): client = self._connect() client.docmd('RSET') + + +class ConnectionCountingSSLController(ConnectionCountingController): + """Count the number of SMTPS connections opened.""" + + def __init__(self, host, port, client_context=None, server_context=None): + super().__init__(host, port, ssl_context=server_context) + self._client_context = client_context + + def _connect(self): + client = smtplib.SMTP_SSL(self.hostname, self.port, + context=self._client_context) + return client + + +class ConnectionCountingSTARTLSController(ConnectionCountingController): + """Count the number of SMTP connections with STARTTLS opened.""" + + def __init__(self, host, port, client_context=None, server_context=None): + super().__init__(host, port) + self._client_context = client_context + self._server_context = server_context + + def factory(self): + return ConnectionCountingSMTP( + self.handler, self._oob_queue, + self.err_queue, tls_context=self._server_context, + require_starttls=True) + + def _connect(self): + client = smtplib.SMTP(self.hostname, self.port) + client.starttls(context=self._client_context) + return client + + def get_connection_count(self): + count = super().get_connection_count() + # This is a hack, since when using STARTTLS for every connection the + # SMTP.connection_made(transport) method is called twice. + # The -1 is there for the last pair of connections made when checking + # the connection count, since one of them is already subtracted in + # ConnectionCountingSMTP.handle_STAT. + # The //2 is there for the rest of the connections which are counted + # twice. + return (count - 1) // 2 diff --git a/src/mailman/testing/ssl_test_cert.crt b/src/mailman/testing/ssl_test_cert.crt new file mode 100644 index 000000000..06ae8ac9d --- /dev/null +++ b/src/mailman/testing/ssl_test_cert.crt @@ -0,0 +1,21 @@ +-----BEGIN CERTIFICATE----- +MIIDeDCCAmCgAwIBAgIJANA3MRKsRL/OMA0GCSqGSIb3DQEBCwUAMFExCzAJBgNV +BAYTAlVTMQswCQYDVQQIDAJNQTEhMB8GA1UECgwYTWFpbG1hbiB0ZXN0IGNlcnRp +ZmljYXRlMRIwEAYDVQQDDAlsb2NhbGhvc3QwHhcNMTcwNTI3MTYxNzIzWhcNMjcw +NTI1MTYxNzIzWjBRMQswCQYDVQQGEwJVUzELMAkGA1UECAwCTUExITAfBgNVBAoM +GE1haWxtYW4gdGVzdCBjZXJ0aWZpY2F0ZTESMBAGA1UEAwwJbG9jYWxob3N0MIIB +IjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAycCl/y9Cpm0jW921eIraIieh +cxXPrYed66SxZwoZNt8StaFfNOeqykIiFpUR/5KYc1Sk0yF2tGfFv6l17xG0fuCI +OkH1EynhXZiPbdcvmqF2W6Fa1g5kZJ//KbhK4rRsUEVp/2pHVjMG6toenM+S/1LE +35XiNyIHF6ZeENAb/zR3ikjYH0MSEq2doBmKd9/m48+z5Vb+MbyIG7jbvN3MQ9f/ +NXAIwcGf37RO5+yNIcvqTV9SMBAZb1l+rMrsks6Ghr3NoBP1kuqJF5vFropKBx7R +1MKvYm2ISL5UaWbFPYs9A4VhtzhMWBLZGHQPFHnR7dub7et8eiC7fbsdx4uyGwID +AQABo1MwUTAdBgNVHQ4EFgQUe1Q+FbXTQjLoeTuSlxQQCeE+jpQwHwYDVR0jBBgw +FoAUe1Q+FbXTQjLoeTuSlxQQCeE+jpQwDwYDVR0TAQH/BAUwAwEB/zANBgkqhkiG +9w0BAQsFAAOCAQEANbliLrarZfinHQ+/TQIOdqolsscw7RtyFtB7wA2uqWNsH/77 +EYDiv8AhpAQB3dzKwGdapgYgsWUe2awSD67TAJg5XS/h9F1PbbjDYGC9v6T7HDPd +SfGV34LbWmH60rE6B1lwyCACnOXhByFGrdicCm2Z073kTp5L7WuAqpwC1f3aMC0u +9goUWHSfqSIPPIv8yiV0vgKk4kP4KV/gi20x74c31pujExCiVGA4GGTXV9hqmAm7 +tZuPTYFN5n60U1tzbFXOuwK6CvCDzWeq27mYwNHTXSJw1GyG4XXM5t2wfSlu/4e4 +xDuZwvP0P7hTME5wC7KGeS7daYSWNCDiHa7g6A== +-----END CERTIFICATE----- diff --git a/src/mailman/testing/ssl_test_key.key b/src/mailman/testing/ssl_test_key.key new file mode 100644 index 000000000..619556893 --- /dev/null +++ b/src/mailman/testing/ssl_test_key.key @@ -0,0 +1,28 @@ +-----BEGIN PRIVATE KEY----- +MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQDJwKX/L0KmbSNb +3bV4itoiJ6FzFc+th53rpLFnChk23xK1oV8056rKQiIWlRH/kphzVKTTIXa0Z8W/ +qXXvEbR+4Ig6QfUTKeFdmI9t1y+aoXZboVrWDmRkn/8puEritGxQRWn/akdWMwbq +2h6cz5L/UsTfleI3IgcXpl4Q0Bv/NHeKSNgfQxISrZ2gGYp33+bjz7PlVv4xvIgb +uNu83cxD1/81cAjBwZ/ftE7n7I0hy+pNX1IwEBlvWX6syuySzoaGvc2gE/WS6okX +m8WuikoHHtHUwq9ibYhIvlRpZsU9iz0DhWG3OExYEtkYdA8UedHt25vt63x6ILt9 +ux3Hi7IbAgMBAAECggEATRjFVmLlAVwrauuqcUn+WZbzZ1sqZZGxk174O/vr7sAI +Ekh8bWcqKOhkxmRo4FVQ1KG/6r6a8g3Fz5weaSFG7EU5Sany0UPrzyyBguP8WQbi +h9l9MNeHHbzWcUbvtvpjeblM7EHcyN/vAMghcqMP9WnXuek47QCf3TXCNIKScE8a +sU01o7Lw4CkZOLP9UyLmoKiQpurj7jWOMq10QOG4693dnfcgz7voPZPrt8OnAo7x +ap4ojK9nWfjFHFxpsddBhXBxzXw/U8FxtzYtCbzxL7iYpxpq0GEc/5eF8O+RE4MX +D1uqrkLfVM4HMOgQoFLrSGcPahYSVRrQ3X+lYV+OQQKBgQDotmodstq4T3D+7buY +J3zi+saEDvcPxgy31934jm465WfNiYSrnq5PqJCkzVpYMPuj0O8za75OoyRK2viT +glBKjP0vxNXh91tTi4eU7LCFPNwOwnt+e3tu+eA4klFpQIEUI8PHlEe4VBGlvpsD +DoPHuIHCouAEXhpu/ck58+QxKQKBgQDd8Rshc7h8PY26Rxrzgjob9PvDHxQODodU +iRUAELscSkr4gwYTgmDL4vL+lOog7IAYGmSOsHLJ19wTbLsaPMPfhUh+zXuvwlA5 +/01uYFp0UM4ax4DdTGPA5qOAIkLY3rL1x6U0eBf6XJhdG8mTW9SjbD1aCbDGdZk6 +9KJVUlDdowKBgD4NHeCLZ1zL+gJP27ynktpnKfXek6xGD/AZhFuZhvT3ZKVerNyi +NDKTbPY0t4lajk7REGcyrI0FXVEEcFHM5qHqVDyfjLRzI4v0YZOpRSxR3Q+mdg10 +2aXuxQXwpfqds41uN+8Ir9MLv6TlXSoEfckMfrUqfvdLLFs6GqT0Tn15AoGBANG0 +05HUKekasCPms8yKrCVmYcyIPQbbK3vw2uro5CNi/1u5UbB1bMi5dCigxGi/jnk3 +1vQMPSoC0Gt6PYAZEmrNISbPOaNk0zE5zgwQ9ucYwuYCw/xWBZtrUensdYU9R5N8 +RNlC8EUb4Mt5Sgn2pwCTcZT1uxaKX3KZXBmKYeZJAoGAAtWlzPxgwDglNhhuLpgW +vSOH0lpHlR//Ne7kMPCfG4lrAaPo5VSfQrjlzoF9tlhu0IuRKnwOg4HmFD9COaLA +RWTNDLMxBct5lZNAjgIzI5nZm6VLioCQkAdThzSBTxpGrIR0iDR99RWf/M4p7cK6 +tiqX285r1nogUpROl0uraV4= +-----END PRIVATE KEY----- -- cgit v1.2.3-70-g09d2