378 lines
13 KiB
Python
Executable File
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)
|