From 3c6ed40dedf356ddb6860aae4a36def85f976431 Mon Sep 17 00:00:00 2001 From: Mario Fetka Date: Mon, 27 Jul 2026 06:43:56 +0200 Subject: [PATCH] Harden SMTP relay test fixtures --- contrib/testing/smtp-relayhost-check.py | 74 +++++++++++++++++++------ contrib/testing/smtp4dev-fixture.py | 59 +++++++++++++------- 2 files changed, 96 insertions(+), 37 deletions(-) diff --git a/contrib/testing/smtp-relayhost-check.py b/contrib/testing/smtp-relayhost-check.py index c0bdea7..ad9965d 100755 --- a/contrib/testing/smtp-relayhost-check.py +++ b/contrib/testing/smtp-relayhost-check.py @@ -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 " diff --git a/contrib/testing/smtp4dev-fixture.py b/contrib/testing/smtp4dev-fixture.py index 8ec66bf..bb21eb6 100755 --- a/contrib/testing/smtp4dev-fixture.py +++ b/contrib/testing/smtp4dev-fixture.py @@ -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)