Files
bongo/contrib/testing/smtp4dev-fixture.py
2026-07-27 06:43:56 +02:00

736 lines
22 KiB
Python
Executable File

#!/usr/bin/env python3
# /****************************************************************************
# * <Novell-copyright>
# * Copyright (c) 2001 Novell, Inc. All Rights Reserved.
# *
# * This program is free software; you can redistribute it and/or
# * modify it under the terms of version 2 of the GNU General Public License
# * as published by the Free Software Foundation.
# *
# * This program 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 this program; if not, contact Novell, Inc.
# *
# * To contact Novell about this file by physical or electronic mail, you
# * may find current contact information at www.novell.com.
# * </Novell-copyright>
# ****************************************************************************/
"""Manage loopback-only smtp4dev relay and provider fixtures."""
from __future__ import annotations
import argparse
from email.message import EmailMessage
import hashlib
import json
import os
from pathlib import Path
import shutil
import signal
import smtplib
import socket
import ssl
import stat
import subprocess
import sys
import time
import urllib.request
import zipfile
DEFAULT_ROOT = Path("/tmp/bongo-smtp4dev-fixture")
DEFAULT_BINARY = "Rnwood.Smtp4dev"
DEFAULT_USERNAME = "provider"
DEFAULT_PASSWORD = "BongoProvider-Test-2026"
FIXTURE_HOSTNAME = "smtp-provider.bongo.test"
PORTS = {
"starttls": 12587,
"smtps": 12465,
"relay": 12525,
"starttls_api": 15080,
"smtps_api": 15081,
"relay_api": 15082,
}
PINNED_ARCHIVE = (
"Rnwood.Smtp4dev-linux-x64-3.16.0-ci20260724100_pr2100.zip"
)
PINNED_SHA256 = (
"4bcff7d9ecbb62d3abe44888ba38502674b9a634c6b87453e43f1af272bd4249"
)
CERTTOOL = "/usr/bin/certtool"
def checked_root(value: str) -> Path:
root = Path(value).resolve()
if root == Path("/") or not str(root).startswith("/tmp/bongo-smtp4dev-"):
raise argparse.ArgumentTypeError(
"fixture root must be below /tmp and start with bongo-smtp4dev-"
)
return root
def paths(root: Path) -> dict[str, Path]:
return {
"tool": root / "tool",
"binary": root / "tool" / DEFAULT_BINARY,
"run": root / "run",
"tls": root / "tls",
"ca_key": root / "tls" / "ca.key",
"ca_cert": root / "tls" / "ca.crt",
"ca_template": root / "tls" / "ca.tmpl",
"server_key": root / "tls" / "server.key",
"server_cert": root / "tls" / "server.crt",
"server_template": root / "tls" / "server.tmpl",
"dotnet": root / "dotnet",
"config": root / "config",
}
def sha256(path: Path) -> str:
digest = hashlib.sha256()
with path.open("rb") as source:
for block in iter(lambda: source.read(1024 * 1024), b""):
digest.update(block)
return digest.hexdigest()
def extract_archive(
archive: Path,
destination: Path,
expected_sha256: str | None,
) -> None:
if archive.name == PINNED_ARCHIVE:
expected_sha256 = PINNED_SHA256
if expected_sha256 is None:
raise RuntimeError(
"an unpinned smtp4dev archive requires --sha256"
)
actual = sha256(archive)
if actual.lower() != expected_sha256.lower():
raise RuntimeError(
f"smtp4dev archive digest mismatch: {actual}"
)
destination.mkdir(parents=True, exist_ok=True)
destination_root = destination.resolve()
with zipfile.ZipFile(archive) as bundle:
for member in bundle.infolist():
target = (destination / member.filename).resolve()
if target != destination_root and destination_root not in target.parents:
raise RuntimeError(
f"unsafe smtp4dev archive path: {member.filename!r}"
)
mode = member.external_attr >> 16
if stat.S_ISLNK(mode):
raise RuntimeError(
f"smtp4dev archive contains symlink: {member.filename!r}"
)
bundle.extractall(destination)
def run_certtool(arguments: list[str]) -> None:
completed = subprocess.run(
[CERTTOOL, *arguments],
check=False,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
)
if completed.returncode != 0:
detail = completed.stderr.decode("utf-8", "replace").strip()
raise RuntimeError(f"certtool failed: {detail}")
def generate_certificate(root: Path) -> None:
values = paths(root)
values["ca_template"].write_text(
"""cn = Bongo smtp4dev Fixture CA
expiration_days = 3
ca
cert_signing_key
crl_signing_key
""",
encoding="ascii",
)
values["server_template"].write_text(
f"""cn = {FIXTURE_HOSTNAME}
dns_name = {FIXTURE_HOSTNAME}
dns_name = localhost
ip_address = 127.0.0.1
expiration_days = 2
signing_key
encryption_key
tls_www_server
""",
encoding="ascii",
)
run_certtool(
[
"--generate-privkey",
"--sec-param",
"high",
"--outfile",
str(values["ca_key"]),
]
)
run_certtool(
[
"--generate-self-signed",
"--load-privkey",
str(values["ca_key"]),
"--template",
str(values["ca_template"]),
"--outfile",
str(values["ca_cert"]),
]
)
run_certtool(
[
"--generate-privkey",
"--sec-param",
"high",
"--outfile",
str(values["server_key"]),
]
)
run_certtool(
[
"--generate-certificate",
"--load-privkey",
str(values["server_key"]),
"--load-ca-certificate",
str(values["ca_cert"]),
"--load-ca-privkey",
str(values["ca_key"]),
"--template",
str(values["server_template"]),
"--outfile",
str(values["server_cert"]),
]
)
os.chmod(values["ca_key"], 0o600)
os.chmod(values["server_key"], 0o600)
os.chmod(values["ca_cert"], 0o644)
os.chmod(values["server_cert"], 0o644)
def prepare(
root: Path,
archive: Path | None,
archive_sha256: str | None = None,
) -> None:
values = paths(root)
for name in ("run", "tls", "dotnet", "config"):
values[name].mkdir(parents=True, exist_ok=True)
if archive is not None:
extract_archive(
archive.resolve(),
values["tool"],
archive_sha256,
)
binary = Path(
os.environ.get("SMTP4DEV_BIN", str(values["binary"]))
).resolve()
if not binary.is_file():
raise RuntimeError(
"smtp4dev binary not found; pass --archive or set SMTP4DEV_BIN"
)
os.chmod(binary, os.stat(binary).st_mode | stat.S_IXUSR)
if not values["ca_cert"].is_file() or not values["server_key"].is_file():
if not Path(CERTTOOL).is_file():
raise RuntimeError(f"certtool not found at {CERTTOOL}")
generate_certificate(root)
print(f"prepared smtp4dev fixture in {root}")
print(f"CA certificate: {values['ca_cert']}")
def instance_paths(root: Path, name: str) -> tuple[Path, Path, Path]:
return (
root / name,
root / "run" / f"{name}.pid",
root / "run" / f"{name}.log",
)
def process_matches(pid: int, pid_path: Path) -> bool:
try:
os.kill(pid, 0)
command_line = Path(f"/proc/{pid}/cmdline").read_bytes().split(b"\0")
name = pid_path.stem
expected_data = (
f"--baseappdatapath={pid_path.parent.parent / name}".encode()
)
if (
not command_line
or Path(os.fsdecode(command_line[0])).name != DEFAULT_BINARY
or expected_data not in command_line
):
return False
return True
except (
FileNotFoundError,
ProcessLookupError,
PermissionError,
):
return False
def matching_pids(pid_path: Path) -> list[int]:
matches: list[int] = []
try:
recorded_pid = int(pid_path.read_text(encoding="ascii").strip())
except (FileNotFoundError, ValueError):
recorded_pid = None
if recorded_pid is not None and process_matches(recorded_pid, pid_path):
matches.append(recorded_pid)
for entry in Path("/proc").iterdir():
if not entry.name.isdigit():
continue
pid = int(entry.name)
if pid not in matches and process_matches(pid, pid_path):
matches.append(pid)
return sorted(matches)
def read_pid(pid_path: Path) -> int | None:
matches = matching_pids(pid_path)
return matches[0] if matches else None
def wait_for_port(port: int, timeout: float = 20.0) -> None:
deadline = time.monotonic() + timeout
while time.monotonic() < deadline:
try:
with socket.create_connection(("127.0.0.1", port), timeout=0.25):
return
except OSError:
time.sleep(0.1)
raise RuntimeError(f"smtp4dev did not open 127.0.0.1:{port}")
def command(
root: Path,
name: str,
tls_mode: str,
smtp_port: int,
api_port: int,
username: str,
password: str,
authentication_required: bool = True,
) -> tuple[list[str], dict[str, str]]:
values = paths(root)
data, _, _ = instance_paths(root, name)
binary = Path(
os.environ.get("SMTP4DEV_BIN", str(values["binary"]))
).resolve()
arguments = [
str(binary),
f"--baseappdatapath={data}",
f"--installpath={binary.parent}",
f"--hostname={FIXTURE_HOSTNAME}",
"--bindaddress=127.0.0.1",
"--disableipv6",
f"--smtpport={smtp_port}",
"--db=messages.db",
"--messagestokeep=1000",
"--sessionstokeep=1000",
f"--tlsmode={tls_mode}",
f"--tlscertificate={values['server_cert']}",
f"--tlscertificateprivatekey={values['server_key']}",
"--secureconnectionrequired",
"--smtpallowanycredentials-",
"--SmtpAuthTypesSecure=PLAIN,LOGIN",
"--SmtpAuthTypesNotSecure=",
"--imapport=",
"--pop3port=",
"--maxmessagesize=33554432",
"--locksettings",
"--nousersettings",
f"--urls=http://127.0.0.1:{api_port}",
]
arguments.append(
"--authenticationrequired"
if authentication_required else "--authenticationrequired-"
)
environment = os.environ.copy()
environment.update(
{
"DOTNET_BUNDLE_EXTRACT_BASE_DIR": str(values["dotnet"]),
"XDG_CONFIG_HOME": str(values["config"]),
"ServerOptions__Users__0__Username": username,
"ServerOptions__Users__0__Password": password,
}
)
return arguments, environment
def start_instance(
root: Path,
name: str,
tls_mode: str,
smtp_port: int,
api_port: int,
username: str,
password: str,
authentication_required: bool = True,
) -> None:
data, pid_path, log_path = instance_paths(root, name)
if read_pid(pid_path) is not None:
raise RuntimeError(f"smtp4dev {name} fixture is already running")
data.mkdir(parents=True, exist_ok=True)
arguments, environment = command(
root, name, tls_mode, smtp_port, api_port, username, password,
authentication_required
)
with log_path.open("ab") as log:
process = subprocess.Popen(
arguments,
stdin=subprocess.DEVNULL,
stdout=log,
stderr=subprocess.STDOUT,
env=environment,
start_new_session=True,
)
pid_path.write_text(f"{process.pid}\n", encoding="ascii")
try:
wait_for_port(smtp_port)
wait_for_port(api_port)
except Exception:
stop_instance(root, name)
raise
def start(root: Path, username: str, password: str) -> None:
prepare(root, None)
started: list[str] = []
try:
start_instance(
root,
"starttls",
"StartTls",
PORTS["starttls"],
PORTS["starttls_api"],
username,
password,
)
started.append("starttls")
start_instance(
root,
"smtps",
"ImplicitTls",
PORTS["smtps"],
PORTS["smtps_api"],
username,
password,
)
started.append("smtps")
start_instance(
root,
"relay",
"StartTls",
PORTS["relay"],
PORTS["relay_api"],
username,
password,
False,
)
started.append("relay")
except Exception:
for name in reversed(started):
stop_instance(root, name)
raise
print(
"started smtp4dev fixtures: "
f"STARTTLS=127.0.0.1:{PORTS['starttls']} "
f"SMTPS=127.0.0.1:{PORTS['smtps']} "
f"relay=127.0.0.1:{PORTS['relay']}"
)
def stop_instance(root: Path, name: str) -> None:
_, pid_path, _ = instance_paths(root, name)
pids = matching_pids(pid_path)
if not pids:
pid_path.unlink(missing_ok=True)
return
for pid in pids:
try:
os.killpg(pid, signal.SIGTERM)
except ProcessLookupError:
pass
deadline = time.monotonic() + 10.0
while time.monotonic() < deadline:
if not any(process_matches(pid, pid_path) for pid in pids):
break
time.sleep(0.1)
else:
for pid in pids:
try:
os.killpg(pid, signal.SIGKILL)
except ProcessLookupError:
pass
pid_path.unlink(missing_ok=True)
def stop(root: Path) -> None:
for name in ("relay", "smtps", "starttls"):
stop_instance(root, name)
print("stopped smtp4dev fixtures")
def tls_context(root: Path) -> ssl.SSLContext:
context = ssl.create_default_context(cafile=str(paths(root)["ca_cert"]))
context.minimum_version = ssl.TLSVersion.TLSv1_2
return context
def authenticate(
root: Path,
mode: str,
username: str,
password: str,
) -> str:
context = tls_context(root)
if mode == "starttls":
with smtplib.SMTP("127.0.0.1", PORTS[mode], timeout=10) as client:
client.ehlo()
if "auth" in client.esmtp_features:
raise RuntimeError("smtp4dev advertised AUTH before STARTTLS")
if "starttls" not in client.esmtp_features:
raise RuntimeError("smtp4dev did not advertise STARTTLS")
client.starttls(context=context)
client.ehlo()
client.login(username, password)
return client.sock.version()
with smtplib.SMTP_SSL(
"127.0.0.1",
PORTS[mode],
timeout=10,
context=context,
) as client:
client.ehlo()
client.login(username, password)
return client.sock.version()
def api_request(
mode: str,
path: str,
method: str = "GET",
) -> bytes:
request = urllib.request.Request(
f"http://127.0.0.1:{PORTS[mode + '_api']}{path}",
method=method,
)
with urllib.request.urlopen(request, timeout=10) as response:
return response.read()
def message_summaries(mode: str) -> list[dict[str, object]]:
document = json.loads(
api_request(mode, "/api/messages?pageSize=1000").decode("utf-8")
)
if isinstance(document, dict):
results = document.get("results")
else:
results = document
if not isinstance(results, list):
raise RuntimeError(f"{mode} returned malformed message list")
return results
def submit_capture(
root: Path,
mode: str,
username: str,
password: str,
token: str,
) -> None:
message = EmailMessage()
message["From"] = f"{username}@{FIXTURE_HOSTNAME}"
message["To"] = "capture@example.test"
message["Subject"] = token
message.set_content(f"Local Bongo smtp4dev fixture capture {token}\n")
context = tls_context(root)
if mode == "starttls":
with smtplib.SMTP("127.0.0.1", PORTS[mode], timeout=10) as client:
client.ehlo()
client.starttls(context=context)
client.ehlo()
client.login(username, password)
client.send_message(message)
else:
with smtplib.SMTP_SSL(
"127.0.0.1",
PORTS[mode],
timeout=10,
context=context,
) as client:
client.ehlo()
client.login(username, password)
client.send_message(message)
def verify_capture(
root: Path,
mode: str,
username: str,
password: str,
) -> None:
token = f"bongo-smtp4dev-{mode}-{time.time_ns()}"
submit_capture(root, mode, username, password, token)
deadline = time.monotonic() + 10.0
found: dict[str, object] | None = None
while time.monotonic() < deadline:
found = next(
(
item
for item in message_summaries(mode)
if item.get("subject") == token
),
None,
)
if found is not None:
break
time.sleep(0.1)
if found is None or not isinstance(found.get("id"), str):
raise RuntimeError(f"{mode} did not expose the captured message")
identifier = found["id"]
source = api_request(mode, f"/api/messages/{identifier}/source")
if token.encode("ascii") not in source:
raise RuntimeError(f"{mode} raw captured message omitted its token")
api_request(mode, f"/api/messages/{identifier}", method="DELETE")
def verify(root: Path, username: str, password: str) -> None:
versions = {
mode: authenticate(root, mode, username, password)
for mode in ("starttls", "smtps")
}
wrong = password + "-wrong"
for mode in ("starttls", "smtps"):
try:
authenticate(root, mode, username, wrong)
except smtplib.SMTPAuthenticationError as error:
if error.smtp_code != 535:
raise RuntimeError(
f"{mode} rejected invalid credentials with "
f"SMTP {error.smtp_code}, expected 535"
) from error
else:
raise RuntimeError(f"{mode} accepted invalid credentials")
for mode in ("starttls", "smtps"):
verify_capture(root, mode, username, password)
context = tls_context(root)
with smtplib.SMTP("127.0.0.1", PORTS["relay"], timeout=10) as client:
client.ehlo()
client.starttls(context=context)
client.ehlo()
message = EmailMessage()
token = f"bongo-smtp4dev-relay-{time.time_ns()}"
message["From"] = "relay@bongo.test"
message["To"] = "capture@example.test"
message["Subject"] = token
message.set_content(f"{token}\n")
client.send_message(message)
print(
"smtp4dev fixture PASS "
f"starttls={PORTS['starttls']}/{versions['starttls']} "
f"smtps={PORTS['smtps']}/{versions['smtps']} "
f"relay={PORTS['relay']}/no-auth "
"auth=valid invalid=535 capture-api=yes"
)
def clear(root: Path) -> None:
del root
for mode in ("relay", "starttls", "smtps"):
for message in message_summaries(mode):
identifier = message.get("id")
if isinstance(identifier, str):
api_request(
mode,
f"/api/messages/{identifier}",
method="DELETE",
)
print("cleared smtp4dev messages")
def status(root: Path) -> int:
running = True
for name in ("starttls", "smtps", "relay"):
_, pid_path, log_path = instance_paths(root, name)
pid = read_pid(pid_path)
print(
f"{name}: "
f"{'running pid=' + str(pid) if pid is not None else 'stopped'} "
f"log={log_path}"
)
running = running and pid is not None
return 0 if running else 1
def reset(root: Path) -> None:
stop(root)
if root.exists():
shutil.rmtree(root)
print(f"removed {root}")
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--root", type=checked_root, default=DEFAULT_ROOT)
parser.add_argument(
"--username",
default=os.environ.get("BONGO_SMTP4DEV_USER", DEFAULT_USERNAME),
)
parser.add_argument(
"--password",
default=os.environ.get("BONGO_SMTP4DEV_PASSWORD", DEFAULT_PASSWORD),
)
subparsers = parser.add_subparsers(dest="action", required=True)
prepare_parser = subparsers.add_parser("prepare")
prepare_parser.add_argument("--archive", type=Path)
prepare_parser.add_argument("--sha256")
subparsers.add_parser("start")
subparsers.add_parser("stop")
subparsers.add_parser("status")
subparsers.add_parser("verify")
subparsers.add_parser("clear")
subparsers.add_parser("reset")
arguments = parser.parse_args()
if arguments.action == "prepare":
prepare(arguments.root, arguments.archive, arguments.sha256)
elif arguments.action == "start":
start(arguments.root, arguments.username, arguments.password)
elif arguments.action == "stop":
stop(arguments.root)
elif arguments.action == "status":
return status(arguments.root)
elif arguments.action == "verify":
verify(arguments.root, arguments.username, arguments.password)
elif arguments.action == "clear":
clear(arguments.root)
elif arguments.action == "reset":
reset(arguments.root)
return 0
if __name__ == "__main__":
try:
raise SystemExit(main())
except (
OSError,
RuntimeError,
smtplib.SMTPException,
subprocess.CalledProcessError,
zipfile.BadZipFile,
) as error:
print(f"smtp4dev-fixture: {error}", file=sys.stderr)
raise SystemExit(1)