mirror of
https://github.com/suitenumerique/messages.git
synced 2026-09-28 20:54:56 +02:00
This PR adds support for 2 MTA out modes : Direct-to-MX and SMTP-relay outbound delivery. Direct mode supports SOCKS5 proxies, and we bundle a new `src/socks-proxy` component to support it. We also add an end-to-end self-check command plus scheduled health-check task with optional Prometheus metrics. --------- Co-authored-by: Bastien Ogier <bastien.ogier@ext.anct.gouv.fr> Co-authored-by: Stanislas Bruhiere <stanislas@bruhiere.fr>
265 lines
7.4 KiB
Python
265 lines
7.4 KiB
Python
import pytest
|
|
import smtplib
|
|
import time
|
|
import logging
|
|
import os
|
|
import socket
|
|
import subprocess
|
|
import ssl
|
|
import struct
|
|
import socks
|
|
from aiosmtpd.controller import Controller
|
|
from aiosmtpd.handlers import Message
|
|
from email.parser import BytesParser
|
|
|
|
# Set up logging
|
|
logging.basicConfig(level=logging.INFO)
|
|
logger = logging.getLogger(__name__)
|
|
|
|
# Parse SOCKS proxy environment variables
|
|
from urllib.parse import urlparse
|
|
from dataclasses import dataclass
|
|
|
|
@dataclass
|
|
class ProxyConfig:
|
|
username: str = None
|
|
password: str = None
|
|
host: str = "localhost"
|
|
port: int = 1080
|
|
|
|
def parse_proxy_env(proxy_env):
|
|
"""Parse SOCKS_PROXY1 or SOCKS_PROXY2 environment variable
|
|
Format: username:password@host:port
|
|
"""
|
|
if not proxy_env:
|
|
return ProxyConfig()
|
|
|
|
try:
|
|
# Add scheme to make it a valid URL for urlparse
|
|
if not proxy_env.startswith(('http://', 'https://', 'socks://')):
|
|
proxy_env = f"socks://{proxy_env}"
|
|
|
|
parsed = urlparse(proxy_env)
|
|
|
|
return ProxyConfig(
|
|
username=parsed.username,
|
|
password=parsed.password,
|
|
host=parsed.hostname or "localhost",
|
|
port=parsed.port or 1080
|
|
)
|
|
except Exception:
|
|
return ProxyConfig()
|
|
|
|
# Parse both proxy configurations
|
|
PROXY1_CONFIG = parse_proxy_env(os.getenv("SOCKS_PROXY1"))
|
|
PROXY2_CONFIG = parse_proxy_env(os.getenv("SOCKS_PROXY2"))
|
|
|
|
|
|
def get_container_ip():
|
|
"""Get the container's IP address automatically"""
|
|
try:
|
|
result = subprocess.run(['hostname', '-I'], capture_output=True, text=True)
|
|
# hostname -I returns space-separated IPs, first one is usually the main one
|
|
return result.stdout.strip().split()[0]
|
|
except:
|
|
return "127.0.0.1" # fallback
|
|
|
|
|
|
class MessageStore:
|
|
"""Simple storage for received email messages"""
|
|
def __init__(self):
|
|
self.messages = []
|
|
|
|
def add_message(self, message_data):
|
|
self.messages.append(message_data)
|
|
|
|
def clear(self):
|
|
self.messages.clear()
|
|
|
|
def get_messages(self):
|
|
return self.messages
|
|
|
|
def get_last_connection_info(self):
|
|
"""Get connection info from the last received message"""
|
|
if self.messages:
|
|
return self.messages[-1].get("connection_info", {})
|
|
return {}
|
|
|
|
def get_connection_info_for_subject(self, subject):
|
|
"""Get connection info for a specific message subject"""
|
|
for message in self.messages:
|
|
if message.get("subject") == subject:
|
|
return message.get("connection_info", {})
|
|
return {}
|
|
|
|
|
|
class MockSMTPHandler(Message):
|
|
"""Handle SMTP messages and store them"""
|
|
def __init__(self, message_store):
|
|
super().__init__()
|
|
self.message_store = message_store
|
|
|
|
async def handle_DATA(self, server, session, envelope):
|
|
message = self.prepare_message(session, envelope)
|
|
# Capture connection info from session
|
|
connection_info = {
|
|
"peer_host": (session.peer or [None, None])[0],
|
|
"peer_port": (session.peer or [None, None])[1],
|
|
}
|
|
self.handle_message(message, envelope.content, connection_info)
|
|
return "250 OK"
|
|
|
|
def handle_message(self, message, raw_message, connection_info):
|
|
parsed_message = BytesParser().parsebytes(message.as_bytes())
|
|
message_data = {
|
|
"from": parsed_message.get("From"),
|
|
"to": parsed_message.get("To"),
|
|
"subject": parsed_message.get("Subject"),
|
|
"raw_message": raw_message,
|
|
"connection_info": connection_info,
|
|
}
|
|
self.message_store.add_message(message_data)
|
|
|
|
|
|
class MockSMTPServer:
|
|
"""Mock SMTP server for testing"""
|
|
def __init__(self, host="0.0.0.0", port=2525):
|
|
self.host = host
|
|
self.port = port
|
|
self.message_store = MessageStore()
|
|
handler = MockSMTPHandler(self.message_store)
|
|
self.controller = Controller(handler, hostname=self.host, port=self.port)
|
|
|
|
def start(self):
|
|
self.controller.start()
|
|
|
|
def stop(self):
|
|
self.controller.stop()
|
|
|
|
def clear_messages(self):
|
|
self.message_store.clear()
|
|
|
|
def get_messages(self):
|
|
return self.message_store.get_messages()
|
|
|
|
|
|
def create_proxied_socket(proxy_host, proxy_port, target_host, target_port, username=None, password=None, timeout=5):
|
|
"""Create a socket connected through a SOCKS proxy"""
|
|
proxy = socks.socksocket()
|
|
if type(timeout) in {int, float}:
|
|
proxy.settimeout(timeout)
|
|
proxy.set_proxy(socks.PROXY_TYPE_SOCKS5, proxy_host, proxy_port, rdns=False, username=username, password=password)
|
|
proxy.connect((target_host, target_port))
|
|
|
|
return proxy
|
|
|
|
class SOCKSClient:
|
|
"""SOCKS client for testing"""
|
|
def __init__(self, proxy_host, proxy_port, username=None, password=None):
|
|
self.proxy_host = proxy_host
|
|
self.proxy_port = proxy_port
|
|
self.username = username
|
|
self.password = password
|
|
|
|
def test_connection(self, target_host, target_port, timeout=5):
|
|
try:
|
|
sock = create_proxied_socket(
|
|
self.proxy_host,
|
|
self.proxy_port,
|
|
target_host,
|
|
target_port,
|
|
self.username,
|
|
self.password,
|
|
timeout
|
|
)
|
|
sock.close()
|
|
return True
|
|
except Exception:
|
|
return False
|
|
|
|
|
|
@pytest.fixture(scope="session")
|
|
def mock_smtp_server():
|
|
server = MockSMTPServer()
|
|
server.start()
|
|
yield server
|
|
server.stop()
|
|
|
|
|
|
@pytest.fixture
|
|
def socks_client():
|
|
return SOCKSClient(
|
|
proxy_host=PROXY1_CONFIG.host,
|
|
proxy_port=PROXY1_CONFIG.port,
|
|
username=PROXY1_CONFIG.username,
|
|
password=PROXY1_CONFIG.password
|
|
)
|
|
|
|
|
|
@pytest.fixture
|
|
def socks_client_proxy2():
|
|
"""SOCKS client using PROXY2 configuration"""
|
|
return SOCKSClient(
|
|
proxy_host=PROXY2_CONFIG.host,
|
|
proxy_port=PROXY2_CONFIG.port,
|
|
username=PROXY2_CONFIG.username,
|
|
password=PROXY2_CONFIG.password
|
|
)
|
|
|
|
|
|
@pytest.fixture
|
|
def smtp_client_direct():
|
|
client = smtplib.SMTP("localhost", 2525)
|
|
client.set_debuglevel(2)
|
|
|
|
yield client
|
|
|
|
try:
|
|
client.quit()
|
|
except:
|
|
pass
|
|
|
|
|
|
class ProxySMTP(smtplib.SMTP):
|
|
def __init__(self, *args, **kwargs):
|
|
if "socks_client" in kwargs:
|
|
self.socks_client = kwargs.pop("socks_client")
|
|
super().__init__(*args, **kwargs)
|
|
|
|
def _get_socket(self, host, port, timeout):
|
|
# This makes it simpler for SMTP_SSL to use the SMTP connect code
|
|
# and just alter the socket connection bit.
|
|
if timeout is not None and not timeout:
|
|
raise ValueError('Non-blocking socket (timeout=0) is not supported')
|
|
if self.debuglevel > 0:
|
|
self._print_debug('connect: to', (host, port), self.source_address)
|
|
|
|
return create_proxied_socket(
|
|
self.socks_client.proxy_host,
|
|
self.socks_client.proxy_port,
|
|
host,
|
|
port,
|
|
self.socks_client.username,
|
|
self.socks_client.password,
|
|
timeout
|
|
)
|
|
|
|
|
|
@pytest.fixture
|
|
def smtp_client_via_proxy(socks_client):
|
|
# Create SMTP client that connects through SOCKS proxy to the container's IP
|
|
|
|
container_ip = get_container_ip()
|
|
print(f"Container IP: {container_ip}")
|
|
|
|
client = ProxySMTP(container_ip, 2525, socks_client=socks_client)
|
|
client.set_debuglevel(2)
|
|
|
|
yield client
|
|
|
|
try:
|
|
client.quit()
|
|
except:
|
|
pass
|
|
|