Support IPv6-only SMTP servers (#16155)
Use Twisted HostnameEndpoint to connect to SMTP servers (instead of connectTCP/connectSSL) which properly supports IPv6-only servers.pull/16181/merge
parent
2d72367367
commit
63b51ef3fb
|
@ -0,0 +1 @@
|
||||||
|
Fix IPv6-related bugs on SMTP settings, adding groundwork to fix similar issues. Contributed by @evilham and @telmich (ungleich.ch).
|
|
@ -23,9 +23,11 @@ from pkg_resources import parse_version
|
||||||
|
|
||||||
import twisted
|
import twisted
|
||||||
from twisted.internet.defer import Deferred
|
from twisted.internet.defer import Deferred
|
||||||
from twisted.internet.interfaces import IOpenSSLContextFactory
|
from twisted.internet.endpoints import HostnameEndpoint
|
||||||
|
from twisted.internet.interfaces import IOpenSSLContextFactory, IProtocolFactory
|
||||||
from twisted.internet.ssl import optionsForClientTLS
|
from twisted.internet.ssl import optionsForClientTLS
|
||||||
from twisted.mail.smtp import ESMTPSender, ESMTPSenderFactory
|
from twisted.mail.smtp import ESMTPSender, ESMTPSenderFactory
|
||||||
|
from twisted.protocols.tls import TLSMemoryBIOFactory
|
||||||
|
|
||||||
from synapse.logging.context import make_deferred_yieldable
|
from synapse.logging.context import make_deferred_yieldable
|
||||||
from synapse.types import ISynapseReactor
|
from synapse.types import ISynapseReactor
|
||||||
|
@ -97,6 +99,7 @@ async def _sendmail(
|
||||||
**kwargs,
|
**kwargs,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
factory: IProtocolFactory
|
||||||
if _is_old_twisted:
|
if _is_old_twisted:
|
||||||
# before twisted 21.2, we have to override the ESMTPSender protocol to disable
|
# before twisted 21.2, we have to override the ESMTPSender protocol to disable
|
||||||
# TLS
|
# TLS
|
||||||
|
@ -110,23 +113,14 @@ async def _sendmail(
|
||||||
factory = build_sender_factory(hostname=smtphost if enable_tls else None)
|
factory = build_sender_factory(hostname=smtphost if enable_tls else None)
|
||||||
|
|
||||||
if force_tls:
|
if force_tls:
|
||||||
reactor.connectSSL(
|
factory = TLSMemoryBIOFactory(optionsForClientTLS(smtphost), True, factory)
|
||||||
smtphost,
|
|
||||||
smtpport,
|
endpoint = HostnameEndpoint(
|
||||||
factory,
|
reactor, smtphost, smtpport, timeout=30, bindAddress=None
|
||||||
optionsForClientTLS(smtphost),
|
|
||||||
timeout=30,
|
|
||||||
bindAddress=None,
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
reactor.connectTCP(
|
|
||||||
smtphost,
|
|
||||||
smtpport,
|
|
||||||
factory,
|
|
||||||
timeout=30,
|
|
||||||
bindAddress=None,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
await make_deferred_yieldable(endpoint.connect(factory))
|
||||||
|
|
||||||
await make_deferred_yieldable(d)
|
await make_deferred_yieldable(d)
|
||||||
|
|
||||||
|
|
||||||
|
|
|
@ -13,19 +13,40 @@
|
||||||
# limitations under the License.
|
# limitations under the License.
|
||||||
|
|
||||||
|
|
||||||
from typing import Callable, List, Tuple
|
from typing import Callable, List, Tuple, Type, Union
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
from zope.interface import implementer
|
from zope.interface import implementer
|
||||||
|
|
||||||
from twisted.internet import defer
|
from twisted.internet import defer
|
||||||
from twisted.internet.address import IPv4Address
|
from twisted.internet._sslverify import ClientTLSOptions
|
||||||
|
from twisted.internet.address import IPv4Address, IPv6Address
|
||||||
from twisted.internet.defer import ensureDeferred
|
from twisted.internet.defer import ensureDeferred
|
||||||
|
from twisted.internet.interfaces import IProtocolFactory
|
||||||
|
from twisted.internet.ssl import ContextFactory
|
||||||
from twisted.mail import interfaces, smtp
|
from twisted.mail import interfaces, smtp
|
||||||
|
|
||||||
from tests.server import FakeTransport
|
from tests.server import FakeTransport
|
||||||
from tests.unittest import HomeserverTestCase, override_config
|
from tests.unittest import HomeserverTestCase, override_config
|
||||||
|
|
||||||
|
|
||||||
|
def TestingESMTPTLSClientFactory(
|
||||||
|
contextFactory: ContextFactory,
|
||||||
|
_connectWrapped: bool,
|
||||||
|
wrappedProtocol: IProtocolFactory,
|
||||||
|
) -> IProtocolFactory:
|
||||||
|
"""We use this to pass through in testing without using TLS, but
|
||||||
|
saving the context information to check that it would have happened.
|
||||||
|
|
||||||
|
Note that this is what the MemoryReactor does on connectSSL.
|
||||||
|
It only saves the contextFactory, but starts the connection with the
|
||||||
|
underlying Factory.
|
||||||
|
See: L{twisted.internet.testing.MemoryReactor.connectSSL}"""
|
||||||
|
|
||||||
|
wrappedProtocol._testingContextFactory = contextFactory # type: ignore[attr-defined]
|
||||||
|
return wrappedProtocol
|
||||||
|
|
||||||
|
|
||||||
@implementer(interfaces.IMessageDelivery)
|
@implementer(interfaces.IMessageDelivery)
|
||||||
class _DummyMessageDelivery:
|
class _DummyMessageDelivery:
|
||||||
def __init__(self) -> None:
|
def __init__(self) -> None:
|
||||||
|
@ -75,7 +96,13 @@ class _DummyMessage:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
class SendEmailHandlerTestCase(HomeserverTestCase):
|
class SendEmailHandlerTestCaseIPv4(HomeserverTestCase):
|
||||||
|
ip_class: Union[Type[IPv4Address], Type[IPv6Address]] = IPv4Address
|
||||||
|
|
||||||
|
def setUp(self) -> None:
|
||||||
|
super().setUp()
|
||||||
|
self.reactor.lookups["localhost"] = "127.0.0.1"
|
||||||
|
|
||||||
def test_send_email(self) -> None:
|
def test_send_email(self) -> None:
|
||||||
"""Happy-path test that we can send email to a non-TLS server."""
|
"""Happy-path test that we can send email to a non-TLS server."""
|
||||||
h = self.hs.get_send_email_handler()
|
h = self.hs.get_send_email_handler()
|
||||||
|
@ -89,7 +116,7 @@ class SendEmailHandlerTestCase(HomeserverTestCase):
|
||||||
(host, port, client_factory, _timeout, _bindAddress) = self.reactor.tcpClients[
|
(host, port, client_factory, _timeout, _bindAddress) = self.reactor.tcpClients[
|
||||||
0
|
0
|
||||||
]
|
]
|
||||||
self.assertEqual(host, "localhost")
|
self.assertEqual(host, self.reactor.lookups["localhost"])
|
||||||
self.assertEqual(port, 25)
|
self.assertEqual(port, 25)
|
||||||
|
|
||||||
# wire it up to an SMTP server
|
# wire it up to an SMTP server
|
||||||
|
@ -105,7 +132,9 @@ class SendEmailHandlerTestCase(HomeserverTestCase):
|
||||||
FakeTransport(
|
FakeTransport(
|
||||||
client_protocol,
|
client_protocol,
|
||||||
self.reactor,
|
self.reactor,
|
||||||
peer_address=IPv4Address("TCP", "127.0.0.1", 1234),
|
peer_address=self.ip_class(
|
||||||
|
"TCP", self.reactor.lookups["localhost"], 1234
|
||||||
|
),
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@ -118,6 +147,10 @@ class SendEmailHandlerTestCase(HomeserverTestCase):
|
||||||
self.assertEqual(str(user), "foo@bar.com")
|
self.assertEqual(str(user), "foo@bar.com")
|
||||||
self.assertIn(b"Subject: test subject", msg)
|
self.assertIn(b"Subject: test subject", msg)
|
||||||
|
|
||||||
|
@patch(
|
||||||
|
"synapse.handlers.send_email.TLSMemoryBIOFactory",
|
||||||
|
TestingESMTPTLSClientFactory,
|
||||||
|
)
|
||||||
@override_config(
|
@override_config(
|
||||||
{
|
{
|
||||||
"email": {
|
"email": {
|
||||||
|
@ -135,17 +168,23 @@ class SendEmailHandlerTestCase(HomeserverTestCase):
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
# there should be an attempt to connect to localhost:465
|
# there should be an attempt to connect to localhost:465
|
||||||
self.assertEqual(len(self.reactor.sslClients), 1)
|
self.assertEqual(len(self.reactor.tcpClients), 1)
|
||||||
(
|
(
|
||||||
host,
|
host,
|
||||||
port,
|
port,
|
||||||
client_factory,
|
client_factory,
|
||||||
contextFactory,
|
|
||||||
_timeout,
|
_timeout,
|
||||||
_bindAddress,
|
_bindAddress,
|
||||||
) = self.reactor.sslClients[0]
|
) = self.reactor.tcpClients[0]
|
||||||
self.assertEqual(host, "localhost")
|
self.assertEqual(host, self.reactor.lookups["localhost"])
|
||||||
self.assertEqual(port, 465)
|
self.assertEqual(port, 465)
|
||||||
|
# We need to make sure that TLS is happenning
|
||||||
|
self.assertIsInstance(
|
||||||
|
client_factory._wrappedFactory._testingContextFactory,
|
||||||
|
ClientTLSOptions,
|
||||||
|
)
|
||||||
|
# And since we use endpoints, they go through reactor.connectTCP
|
||||||
|
# which works differently to connectSSL on the testing reactor
|
||||||
|
|
||||||
# wire it up to an SMTP server
|
# wire it up to an SMTP server
|
||||||
message_delivery = _DummyMessageDelivery()
|
message_delivery = _DummyMessageDelivery()
|
||||||
|
@ -160,7 +199,9 @@ class SendEmailHandlerTestCase(HomeserverTestCase):
|
||||||
FakeTransport(
|
FakeTransport(
|
||||||
client_protocol,
|
client_protocol,
|
||||||
self.reactor,
|
self.reactor,
|
||||||
peer_address=IPv4Address("TCP", "127.0.0.1", 1234),
|
peer_address=self.ip_class(
|
||||||
|
"TCP", self.reactor.lookups["localhost"], 1234
|
||||||
|
),
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@ -172,3 +213,11 @@ class SendEmailHandlerTestCase(HomeserverTestCase):
|
||||||
user, msg = message_delivery.messages.pop()
|
user, msg = message_delivery.messages.pop()
|
||||||
self.assertEqual(str(user), "foo@bar.com")
|
self.assertEqual(str(user), "foo@bar.com")
|
||||||
self.assertIn(b"Subject: test subject", msg)
|
self.assertIn(b"Subject: test subject", msg)
|
||||||
|
|
||||||
|
|
||||||
|
class SendEmailHandlerTestCaseIPv6(SendEmailHandlerTestCaseIPv4):
|
||||||
|
ip_class = IPv6Address
|
||||||
|
|
||||||
|
def setUp(self) -> None:
|
||||||
|
super().setUp()
|
||||||
|
self.reactor.lookups["localhost"] = "::1"
|
||||||
|
|
|
@ -12,6 +12,7 @@
|
||||||
# See the License for the specific language governing permissions and
|
# See the License for the specific language governing permissions and
|
||||||
# limitations under the License.
|
# limitations under the License.
|
||||||
import hashlib
|
import hashlib
|
||||||
|
import ipaddress
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
|
@ -45,7 +46,7 @@ import attr
|
||||||
from typing_extensions import ParamSpec
|
from typing_extensions import ParamSpec
|
||||||
from zope.interface import implementer
|
from zope.interface import implementer
|
||||||
|
|
||||||
from twisted.internet import address, threads, udp
|
from twisted.internet import address, tcp, threads, udp
|
||||||
from twisted.internet._resolver import SimpleResolverComplexifier
|
from twisted.internet._resolver import SimpleResolverComplexifier
|
||||||
from twisted.internet.defer import Deferred, fail, maybeDeferred, succeed
|
from twisted.internet.defer import Deferred, fail, maybeDeferred, succeed
|
||||||
from twisted.internet.error import DNSLookupError
|
from twisted.internet.error import DNSLookupError
|
||||||
|
@ -567,6 +568,8 @@ class ThreadedMemoryReactorClock(MemoryReactorClock):
|
||||||
conn = super().connectTCP(
|
conn = super().connectTCP(
|
||||||
host, port, factory, timeout=timeout, bindAddress=None
|
host, port, factory, timeout=timeout, bindAddress=None
|
||||||
)
|
)
|
||||||
|
if self.lookups and host in self.lookups:
|
||||||
|
validate_connector(conn, self.lookups[host])
|
||||||
|
|
||||||
callback = self._tcp_callbacks.get((host, port))
|
callback = self._tcp_callbacks.get((host, port))
|
||||||
if callback:
|
if callback:
|
||||||
|
@ -599,6 +602,55 @@ class ThreadedMemoryReactorClock(MemoryReactorClock):
|
||||||
super().advance(0)
|
super().advance(0)
|
||||||
|
|
||||||
|
|
||||||
|
def validate_connector(connector: tcp.Connector, expected_ip: str) -> None:
|
||||||
|
"""Try to validate the obtained connector as it would happen when
|
||||||
|
synapse is running and the conection will be established.
|
||||||
|
|
||||||
|
This method will raise a useful exception when necessary, else it will
|
||||||
|
just do nothing.
|
||||||
|
|
||||||
|
This is in order to help catch quirks related to reactor.connectTCP,
|
||||||
|
since when called directly, the connector's destination will be of type
|
||||||
|
IPv4Address, with the hostname as the literal host that was given (which
|
||||||
|
could be an IPv6-only host or an IPv6 literal).
|
||||||
|
|
||||||
|
But when called from reactor.connectTCP *through* e.g. an Endpoint, the
|
||||||
|
connector's destination will contain the specific IP address with the
|
||||||
|
correct network stack class.
|
||||||
|
|
||||||
|
Note that testing code paths that use connectTCP directly should not be
|
||||||
|
affected by this check, unless they specifically add a test with a
|
||||||
|
matching reactor.lookups[HOSTNAME] = "IPv6Literal", where reactor is of
|
||||||
|
type ThreadedMemoryReactorClock.
|
||||||
|
For an example of implementing such tests, see test/handlers/send_email.py.
|
||||||
|
"""
|
||||||
|
destination = connector.getDestination()
|
||||||
|
|
||||||
|
# We use address.IPv{4,6}Address to check what the reactor thinks it is
|
||||||
|
# is sending but check for validity with ipaddress.IPv{4,6}Address
|
||||||
|
# because they fail with IPs on the wrong network stack.
|
||||||
|
cls_mapping = {
|
||||||
|
address.IPv4Address: ipaddress.IPv4Address,
|
||||||
|
address.IPv6Address: ipaddress.IPv6Address,
|
||||||
|
}
|
||||||
|
|
||||||
|
cls = cls_mapping.get(destination.__class__)
|
||||||
|
|
||||||
|
if cls is not None:
|
||||||
|
try:
|
||||||
|
cls(expected_ip)
|
||||||
|
except Exception as exc:
|
||||||
|
raise ValueError(
|
||||||
|
"Invalid IP type and resolution for %s. Expected %s to be %s"
|
||||||
|
% (destination, expected_ip, cls.__name__)
|
||||||
|
) from exc
|
||||||
|
else:
|
||||||
|
raise ValueError(
|
||||||
|
"Unknown address type %s for %s"
|
||||||
|
% (destination.__class__.__name__, destination)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class ThreadPool:
|
class ThreadPool:
|
||||||
"""
|
"""
|
||||||
Threadless thread pool.
|
Threadless thread pool.
|
||||||
|
|
Loading…
Reference in New Issue