Harden SMTP relay test fixtures
This commit is contained in:
@@ -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 "
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user