Harden SMTP relay test fixtures

This commit is contained in:
Mario Fetka
2026-07-27 06:43:56 +02:00
parent 7f1665c270
commit 3c6ed40ded
2 changed files with 96 additions and 37 deletions
+56 -18
View File
@@ -25,14 +25,20 @@ import time
FIXTURE_ROOT = Path(
os.environ.get("BONGO_TEST_SMTP4DEV_ROOT", "/tmp/bongo-smtp4dev-fixture")
)
TIMEOUT = float(os.environ.get("BONGO_TEST_TIMEOUT", "45"))
TOKEN = f"smtp09-{os.getpid()}-{time.time_ns()}"
SYSTEM_CA_FILE = Path(
os.environ.get(
"BONGO_TEST_RELAY_CA_FILE",
f"/etc/bongo/ssl.d/{TOKEN}-relay-ca.pem",
)
)
PASSWORD_FILE = Path(
os.environ.get(
"BONGO_TEST_RELAY_PASSWORD_FILE",
"/tmp/bongo-smtp09-relay.password",
f"/etc/bongo/{TOKEN}-relay.password",
)
)
TIMEOUT = float(os.environ.get("BONGO_TEST_TIMEOUT", "45"))
TOKEN = f"smtp09-{os.getpid()}-{time.time_ns()}"
class SMTP09Error(RuntimeError):
@@ -61,19 +67,51 @@ def restart_bongo() -> None:
SMTP07.wait_for_bongo()
def write_password(value: str) -> None:
def write_secure_file(
path: Path,
data: bytes,
*,
mode: int,
group: int,
) -> None:
path.parent.mkdir(mode=0o750, parents=True, exist_ok=True)
descriptor = os.open(
PASSWORD_FILE,
os.O_WRONLY | os.O_CREAT | os.O_TRUNC | os.O_NOFOLLOW,
0o600,
path,
os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW,
mode,
)
try:
with os.fdopen(descriptor, "wb") as stream:
stream.write(data)
stream.flush()
os.fsync(stream.fileno())
os.chown(path, 0, group)
os.chmod(path, mode)
except Exception:
path.unlink(missing_ok=True)
raise
def write_password(value: str) -> None:
PASSWORD_FILE.unlink(missing_ok=True)
write_secure_file(
PASSWORD_FILE,
f"{value}\n".encode(),
mode=0o640,
group=grp.getgrnam("bongo").gr_gid,
)
def stage_ca_certificate() -> None:
source = SMTP4DEV.paths(FIXTURE_ROOT)["ca_cert"]
if not source.is_file():
raise SMTP09Error(f"smtp4dev CA certificate is missing: {source}")
write_secure_file(
SYSTEM_CA_FILE,
source.read_bytes(),
mode=0o644,
group=0,
)
with os.fdopen(descriptor, "w", encoding="utf-8") as stream:
stream.write(value)
stream.write("\n")
stream.flush()
os.fsync(stream.fileno())
os.chown(PASSWORD_FILE, 0, grp.getgrnam("bongo").gr_gid)
os.chmod(PASSWORD_FILE, 0o640)
def relay_configuration(
@@ -92,9 +130,7 @@ def relay_configuration(
"relay_port": port,
"relay_tls_security_level": tls_security_level,
"relay_tls_wrapper_mode": tls_wrapper_mode,
"relay_tls_ca_file": str(
SMTP4DEV.paths(FIXTURE_ROOT)["ca_cert"]
),
"relay_tls_ca_file": str(SYSTEM_CA_FILE),
"relay_username": (
SMTP4DEV.DEFAULT_USERNAME if authenticated else ""
),
@@ -155,7 +191,7 @@ def assert_dkim_signature(
if not isinstance(identifier, str):
raise SMTP09Error(f"smtp4dev {mode} omitted the captured message id")
source = SMTP4DEV.api_request(
mode, f"/api/messages/{identifier}/source"
mode, f"/api/messages/{identifier}/raw"
)
message = BytesParser(policy=SMTP).parsebytes(source)
signatures = message.get_all("DKIM-Signature", [])
@@ -280,6 +316,7 @@ def perform_test() -> None:
)
fixture_started = True
SMTP4DEV.clear(FIXTURE_ROOT)
stage_ca_certificate()
noauth_marker = f"{TOKEN}-noauth-starttls"
noauth = relay_configuration(
@@ -346,6 +383,7 @@ def perform_test() -> None:
if fixture_started:
SMTP4DEV.stop(FIXTURE_ROOT)
PASSWORD_FILE.unlink(missing_ok=True)
SYSTEM_CA_FILE.unlink(missing_ok=True)
print(
"SMTP-09 PASS hostname=localhost ports=12525/12465 "
+40 -19
View File
@@ -255,9 +255,8 @@ def instance_paths(root: Path, name: str) -> tuple[Path, Path, Path]:
)
def read_pid(pid_path: Path) -> int | None:
def process_matches(pid: int, pid_path: Path) -> bool:
try:
pid = int(pid_path.read_text(encoding="ascii").strip())
os.kill(pid, 0)
command_line = Path(f"/proc/{pid}/cmdline").read_bytes().split(b"\0")
name = pid_path.stem
@@ -269,15 +268,37 @@ def read_pid(pid_path: Path) -> int | None:
or Path(os.fsdecode(command_line[0])).name != DEFAULT_BINARY
or expected_data not in command_line
):
return None
return pid
return False
return True
except (
FileNotFoundError,
ProcessLookupError,
PermissionError,
ValueError,
):
return None
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:
@@ -432,26 +453,26 @@ def start(root: Path, username: str, password: str) -> None:
def stop_instance(root: Path, name: str) -> None:
_, pid_path, _ = instance_paths(root, name)
pid = read_pid(pid_path)
if pid is None:
pids = matching_pids(pid_path)
if not pids:
pid_path.unlink(missing_ok=True)
return
try:
os.killpg(pid, signal.SIGTERM)
except ProcessLookupError:
pass
for pid in pids:
try:
os.killpg(pid, signal.SIGTERM)
except ProcessLookupError:
pass
deadline = time.monotonic() + 10.0
while time.monotonic() < deadline:
try:
os.kill(pid, 0)
except ProcessLookupError:
if not any(process_matches(pid, pid_path) for pid in pids):
break
time.sleep(0.1)
else:
try:
os.killpg(pid, signal.SIGKILL)
except ProcessLookupError:
pass
for pid in pids:
try:
os.killpg(pid, signal.SIGKILL)
except ProcessLookupError:
pass
pid_path.unlink(missing_ok=True)