bebleo / smtpdfix

A SMTP server for use as a pytest fixture that implements encryption and authentication.
MIT License
15 stars 5 forks source link

v0.5.2 breaks on Linux when IPv6 is disabled #391

Open moenoel opened 3 months ago

moenoel commented 3 months ago

smtpdfix version 5.2 breaks when used in the Docker image "python:3-alpine" (just "python:3" works), because the IPv6 localhost address ::1 is not available.

The attached demo.zip is a minimal example to reproduce the error. The same example works when 5.1 is installed instead.

To run the example just execute the following in the demo directory:

docker build -t demo .
docker run -it --rm demo:latest
pytest error ``` ================================================================ test session starts ================================================================= platform linux -- Python 3.11.4, pytest-8.1.1, pluggy-1.5.0 rootdir: /test plugins: smtpdfix-0.5.2 collected 1 item test.py E [100%] ======================================================================= ERRORS ======================================================================= __________________________________________________________ ERROR at setup of test_sendmail ___________________________________________________________ tmp_path_factory = TempPathFactory(_given_basetemp=None, _trace=, _basetemp=PosixPath('/tmp/pytest-of-root/pytest-0'), _retention_count=3, _retention_policy='all') @pytest.fixture def smtpd( tmp_path_factory: pytest.TempPathFactory ) -> Generator[AuthController, None, None]: """A small SMTP server for use when testing applications that send email messages. To access the messages call `smtpd.messages` which returns a copy of the list of messages sent to the server. Example: def test_mail(smtpd): from smtplib import SMTP with SMTP(smtpd.hostname, smtpd.port) as client: code, resp = client.noop() assert code == 250 """ if os.getenv("SMTPD_SSL_CERTIFICATE_FILE") is None: path = tmp_path_factory.mktemp("certs") cert, _ = _generate_certs(path, separate_key=False) os.environ["SMTPD_SSL_CERTIFICATE_FILE"] = str(cert.resolve()) > with SMTPDFix() as fixture: /usr/local/lib/python3.11/site-packages/smtpdfix/fixture.py:85: _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ /usr/local/lib/python3.11/site-packages/smtpdfix/fixture.py:58: in __enter__ self.controller.start() /usr/local/lib/python3.11/site-packages/aiosmtpd/controller.py:274: in start raise self._thread_exception /usr/local/lib/python3.11/site-packages/smtpdfix/controller.py:136: in _run srv: AsyncServer = self.loop.run_until_complete(srv_coro) /usr/local/lib/python3.11/asyncio/base_events.py:653: in run_until_complete return future.result() _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ self = <_UnixSelectorEventLoop running=False closed=False debug=False> protocol_factory = >, host = 'localhost' port = 43091 async def create_server( self, protocol_factory, host=None, port=None, *, family=socket.AF_UNSPEC, flags=socket.AI_PASSIVE, sock=None, backlog=100, ssl=None, reuse_address=None, reuse_port=None, ssl_handshake_timeout=None, ssl_shutdown_timeout=None, start_serving=True): """Create a TCP server. The host parameter can be a string, in that case the TCP server is bound to host and port. The host parameter can also be a sequence of strings and in that case the TCP server is bound to all hosts of the sequence. If a host appears multiple times (possibly indirectly e.g. when hostnames resolve to the same IP address), the server is only bound once to that host. Return a Server object which can be used to stop the service. This method is a coroutine. """ if isinstance(ssl, bool): raise TypeError('ssl argument must be an SSLContext or None') if ssl_handshake_timeout is not None and ssl is None: raise ValueError( 'ssl_handshake_timeout is only meaningful with ssl') if ssl_shutdown_timeout is not None and ssl is None: raise ValueError( 'ssl_shutdown_timeout is only meaningful with ssl') if sock is not None: _check_ssl_socket(sock) if host is not None or port is not None: if sock is not None: raise ValueError( 'host/port and sock can not be specified at the same time') if reuse_address is None: reuse_address = os.name == "posix" and sys.platform != "cygwin" sockets = [] if host == '': hosts = [None] elif (isinstance(host, str) or not isinstance(host, collections.abc.Iterable)): hosts = [host] else: hosts = host fs = [self._create_server_getaddrinfo(host, port, family=family, flags=flags) for host in hosts] infos = await tasks.gather(*fs) infos = set(itertools.chain.from_iterable(infos)) completed = False try: for res in infos: af, socktype, proto, canonname, sa = res try: sock = socket.socket(af, socktype, proto) except socket.error: # Assume it's a bad family/type/protocol combination. if self._debug: logger.warning('create_server() failed to create ' 'socket.socket(%r, %r, %r)', af, socktype, proto, exc_info=True) continue sockets.append(sock) if reuse_address: sock.setsockopt( socket.SOL_SOCKET, socket.SO_REUSEADDR, True) if reuse_port: _set_reuseport(sock) # Disable IPv4/IPv6 dual stack support (enabled by # default on Linux) which makes a single socket # listen on both address families. if (_HAS_IPv6 and af == socket.AF_INET6 and hasattr(socket, 'IPPROTO_IPV6')): sock.setsockopt(socket.IPPROTO_IPV6, socket.IPV6_V6ONLY, True) try: sock.bind(sa) except OSError as err: > raise OSError(err.errno, 'error while attempting ' 'to bind on address %r: %s' % (sa, err.strerror.lower())) from None E OSError: [Errno 99] error while attempting to bind on address ('::1', 43091, 0, 0): address not available /usr/local/lib/python3.11/asyncio/base_events.py:1525: OSError ============================================================== short test summary info =============================================================== ERROR test.py::test_sendmail - OSError: [Errno 99] error while attempting to bind on address ('::1', 43091, 0, 0): address not available ================================================================= 1 error in 10.11s ================================================================== ```
bebleo commented 3 months ago

Hi @moenoel, Thanks for taking the time to report this issue.

I've attempted to reproduce the error locally (using Docker Desktop on macOS 14.4.1) but have been unsuccessful. If you, or anyone else, have a suggestion to simulate/reproduce this bug -- ideally as pytest test outside of docker -- it would be appreciated.

moenoel commented 3 months ago

The issue seems to be generally related to disabling IPv6 on Linux. I tested on a Debian 12 VM. From a base install with nothing touched and using requirements.txt and test.py from the demo archive, the following works:

sudo apt-get install python3 python3-pip python3-venv
python3 -m venv venv
. venv/bin/activate
pip install -r requirements.txt
pytest test.py

But after calling sysctl -w net.ipv6.conf.all.disable_ipv6=1 and then running pytest test.py again, I get the above error.