Files
bongo/contrib/testing/smtp-internal-normalization-check.py
2026-07-24 07:51:26 +02:00

378 lines
13 KiB
Python
Executable File

#!/usr/bin/env python3
# This program is free software, licensed under the terms of the GNU GPL.
# See the Bongo COPYING file for full details.
# Copyright (c) 2026 Bongo Project contributors
"""Verify internal SMTP sender normalization in the real Queue envelope."""
from __future__ import annotations
import copy
import json
import os
import re
import socket
import subprocess
import sys
import time
ADMIN = os.environ.get("BONGO_TEST_ADMIN", "/usr/bin/bongo-admin")
QUEUE_TOOL = os.environ.get(
"BONGO_TEST_QUEUE_TOOL", "/usr/bin/bongo-queuetool")
SYSTEMCTL = os.environ.get("BONGO_TEST_SYSTEMCTL", "/usr/bin/systemctl")
HOST = os.environ.get("BONGO_TEST_INTERNAL_HOST", "172.16.11.190")
PORT = int(os.environ.get("BONGO_TEST_INTERNAL_PORT", "26"))
SOURCE = os.environ.get("BONGO_TEST_ALLOWED_SOURCE", HOST)
DOMAIN = os.environ.get("BONGO_TEST_DOMAIN", "bongo.test")
DEVICE = os.environ.get("BONGO_TEST_DEVICE", "matrix")
RECIPIENT = os.environ.get(
"BONGO_TEST_REMOTE_RECIPIENT", "capture@normalization.invalid")
TIMEOUT = float(os.environ.get("BONGO_TEST_TIMEOUT", "10"))
STARTUP_GRACE = float(os.environ.get("BONGO_TEST_STARTUP_GRACE", "2"))
ALLOW_LIVE = os.environ.get("BONGO_ALLOW_LIVE_SMTP_TEST") == "1"
RESPONSE = re.compile(rb"^([0-9]{3})([- ])(.*)\r\n$")
QUEUE_ID = re.compile(r"^[0-9]{3}-[0-9a-f]+$")
TOKEN = f"smtp05-{os.getpid()}-{int(time.time())}"
class NormalizationCheckError(RuntimeError):
"""Raised when live sender normalization differs from its contract."""
def run(
arguments: list[str],
*,
input_text: str | None = None,
binary: bool = False,
check: bool = True,
) -> subprocess.CompletedProcess:
completed = subprocess.run(
arguments,
input=input_text,
capture_output=True,
text=not binary,
check=False,
)
if check and completed.returncode:
stdout = completed.stdout
stderr = completed.stderr
if binary:
stdout = stdout.decode("utf-8", "replace")
stderr = stderr.decode("utf-8", "replace")
raise NormalizationCheckError(
f"{' '.join(arguments)} failed: "
f"{stderr.strip() or stdout.strip()}")
return completed
def admin(*arguments: str, input_text: str | None = None) -> str:
return run(
["sudo", "-n", ADMIN, *arguments],
input_text=input_text,
).stdout
def queue(*arguments: str, binary: bool = False,
check: bool = True) -> subprocess.CompletedProcess:
return run(
["sudo", "-n", "-u", "bongo", QUEUE_TOOL, *arguments],
binary=binary,
check=check,
)
def read_configuration() -> dict:
try:
configuration = json.loads(admin("__config-read", "smtp"))
except json.JSONDecodeError as error:
raise NormalizationCheckError(
"bongo-admin returned invalid SMTP configuration") from error
if not isinstance(configuration, dict):
raise NormalizationCheckError("SMTP configuration is not an object")
return configuration
def replace_configuration(configuration: dict) -> None:
admin(
"__config-replace", "smtp",
input_text=json.dumps(configuration, separators=(",", ":")),
)
def queue_ids() -> set[str]:
identifiers: set[str] = set()
for line in queue("list").stdout.splitlines():
fields = line.split()
if fields and QUEUE_ID.fullmatch(fields[0]):
identifiers.add(fields[0])
return identifiers
def token_queue_ids(baseline: set[str]) -> list[str]:
identifiers: list[str] = []
for queue_id in queue_ids() - baseline:
message = queue("message", queue_id, binary=True, check=False)
if message.returncode == 0 and TOKEN.encode("ascii") in message.stdout:
identifiers.append(queue_id)
return identifiers
def cleanup_entries(baseline: set[str]) -> None:
for queue_id in token_queue_ids(baseline):
queue("delete", queue_id, check=False)
def wait_port() -> None:
deadline = time.monotonic() + 30
consecutive = 0
while time.monotonic() < deadline:
client = None
try:
client = SMTPConnection()
client.response(220)
client.command("QUIT", 221)
consecutive += 1
if consecutive == 2:
return
time.sleep(0.25)
except (OSError, NormalizationCheckError):
consecutive = 0
time.sleep(0.1)
finally:
if client is not None:
client.close()
raise NormalizationCheckError(
f"internal SMTP did not become ready at {HOST}:{PORT}")
def restart_bongo() -> None:
run(["sudo", "-n", SYSTEMCTL, "restart", "bongo.service"])
time.sleep(STARTUP_GRACE)
wait_port()
if run(
["sudo", "-n", SYSTEMCTL, "is-active", "bongo.service"]
).stdout.strip() != "active":
raise NormalizationCheckError("bongo.service is not active")
class SMTPConnection:
def __init__(self) -> None:
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
sock.settimeout(TIMEOUT)
try:
sock.bind((SOURCE, 0))
sock.connect((HOST, PORT))
except BaseException:
sock.close()
raise
self.socket = sock
self.reader = sock.makefile("rb")
def close(self) -> None:
try:
self.reader.close()
finally:
self.socket.close()
def response(self, expected: int) -> list[bytes]:
lines: list[bytes] = []
response_code = -1
while True:
line = self.reader.readline(8194)
if not line:
raise NormalizationCheckError(
f"connection closed while waiting for SMTP {expected}")
match = RESPONSE.match(line)
if match is None:
raise NormalizationCheckError(
f"malformed SMTP response: {line!r}")
code = int(match.group(1))
if response_code == -1:
response_code = code
elif code != response_code:
raise NormalizationCheckError(
f"multiline SMTP response changed code: {line!r}")
if code != expected:
raise NormalizationCheckError(
f"expected SMTP {expected}, received {line!r}")
lines.append(match.group(3))
if match.group(2) == b" ":
return lines
def command(self, command: str, expected: int) -> list[bytes]:
self.socket.sendall(command.encode("ascii") + b"\r\n")
return self.response(expected)
def submit(sender: str | None, label: str) -> None:
client = SMTPConnection()
try:
client.response(220)
client.command("EHLO smtp05-device.bongo.test", 250)
if sender is None:
client.command("MAIL FROM:<>", 250)
else:
client.command(f"MAIL FROM:<{sender}>", 250)
client.command(f"RCPT TO:<{RECIPIENT}>", 250)
client.command("DATA", 354)
message = (
f"From: device@{DOMAIN}\r\n"
f"To: {RECIPIENT}\r\n"
f"Subject: SMTP-05 {label} {TOKEN}\r\n"
f"Message-ID: <{TOKEN}-{label}@{DOMAIN}>\r\n"
"\r\n"
f"Internal sender normalization check {TOKEN} {label}\r\n"
).encode("ascii")
client.socket.sendall(message + b".\r\n")
client.response(250)
client.command("QUIT", 221)
finally:
client.close()
def wait_envelope(baseline: set[str], expected_sender: str) -> str:
deadline = time.monotonic() + 20
while time.monotonic() < deadline:
matches = token_queue_ids(baseline)
if len(matches) > 1:
raise NormalizationCheckError(
f"test created multiple Queue entries: {matches!r}")
if matches:
held = queue("hold", matches[0], check=False)
if held.returncode:
time.sleep(0.1)
continue
queue_id = held.stdout.strip()
if not QUEUE_ID.fullmatch(queue_id) or not queue_id.startswith(
"999-"
):
raise NormalizationCheckError(
f"invalid held Queue ID: {queue_id!r}")
envelope = queue("show", queue_id, binary=True).stdout
from_lines = [
line[1:].decode("utf-8", "strict").split()
for line in envelope.splitlines() if line.startswith(b"F")
]
if len(from_lines) != 1 or not from_lines[0]:
raise NormalizationCheckError(
f"Queue entry {queue_id} has invalid FROM envelope")
actual_sender = from_lines[0][0]
if actual_sender != expected_sender:
raise NormalizationCheckError(
f"expected envelope sender {expected_sender!r}, "
f"received {actual_sender!r}")
return queue_id
time.sleep(0.1)
raise NormalizationCheckError(
f"no deferred Queue entry containing {TOKEN} appeared")
def check_case(
baseline: set[str], label: str, sender: str | None, expected: str
) -> None:
submit(sender, label)
queue_id = wait_envelope(baseline, expected)
queue("delete", queue_id)
def main() -> int:
if not ALLOW_LIVE:
raise NormalizationCheckError(
"set BONGO_ALLOW_LIVE_SMTP_TEST=1 for this temporary "
"live-configuration test")
if STARTUP_GRACE < 0:
raise NormalizationCheckError(
"BONGO_TEST_STARTUP_GRACE cannot be negative")
original = read_configuration()
if not original.get("internal_relay_enabled"):
raise NormalizationCheckError("internal SMTP is not enabled")
if original.get("internal_relay_bind_address") != HOST:
raise NormalizationCheckError(
"BONGO_TEST_INTERNAL_HOST does not match live configuration")
if int(original.get("internal_relay_port", 0)) != PORT:
raise NormalizationCheckError(
"BONGO_TEST_INTERNAL_PORT does not match live configuration")
if original.get("internal_relay_domain") != DOMAIN:
raise NormalizationCheckError(
"BONGO_TEST_DOMAIN does not match internal relay domain")
baseline = queue_ids()
test_configuration = copy.deepcopy(original)
test_configuration["internal_relay_device_mappings"] = [
f"{SOURCE}={DEVICE}"]
test_configuration["internal_relay_dns_suffixes"] = []
test_configuration["use_relay_host"] = True
test_configuration["relay_host"] = "127.0.0.2"
test_configuration["internal_relay_messages_per_minute"] = max(
100, int(original.get("internal_relay_messages_per_minute", 0)))
test_configuration["internal_relay_recipients_per_minute"] = max(
100, int(original.get("internal_relay_recipients_per_minute", 0)))
restored = False
try:
replace_configuration(test_configuration)
restart_bongo()
cases = [
("device-root", "root@localhost",
f"root+{DEVICE}@{DOMAIN}"),
("local-daemon", "daemon@localdomain",
f"daemon@{DOMAIN}"),
("localhost-fqdn", "ROOT@LOCALHOST.LOCALDOMAIN",
f"ROOT+{DEVICE}@{DOMAIN}"),
("subdomain", f"root@matrix.{DOMAIN}",
f"root+matrix@{DOMAIN}"),
("nested-subdomain", f"alerts@rack3.matrix.{DOMAIN}",
f"alerts+rack3.matrix@{DOMAIN}"),
("send-only-subdomain", f"rsync@backup.{DOMAIN}",
f"rsync+backup@{DOMAIN}"),
("hosted-domain", f"blog@{DOMAIN}",
f"blog@{DOMAIN}"),
("external-boundary", f"root@attacker{DOMAIN}",
f"root+{DEVICE}@{DOMAIN}"),
("external", "sender@example.net", f"sender@{DOMAIN}"),
("null-sender", None, "-"),
]
for label, sender, expected in cases:
print(f"SMTP-05: {label}", file=sys.stderr, flush=True)
check_case(baseline, label, sender, expected)
cleanup_entries(baseline)
replace_configuration(original)
restart_bongo()
restored = read_configuration() == original
if not restored:
raise NormalizationCheckError(
"SMTP configuration did not round-trip after restoration")
finally:
cleanup_entries(baseline)
if not restored:
replace_configuration(original)
restart_bongo()
if token_queue_ids(baseline):
raise NormalizationCheckError("test Queue entries remain after cleanup")
print(
"SMTP-05 PASS "
f"host={HOST}:{PORT} source={SOURCE} device={DEVICE} cases=10 "
"queue-envelope=yes queue-held=yes fcrdns=unit-tested "
"external-boundary=yes "
"internet-delivery=no restored=yes"
)
return 0
if __name__ == "__main__":
try:
raise SystemExit(main())
except (
NormalizationCheckError,
json.JSONDecodeError,
OSError,
UnicodeError,
ValueError,
) as error:
print(f"SMTP-05 FAIL: {error}")
raise SystemExit(1)