Files
bongo/contrib/testing/smtp-pipelining-check.py
T
2026-07-27 14:19:06 +02:00

326 lines
10 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 SMTP PIPELINING response order and transaction recovery."""
from __future__ import annotations
import os
import re
import socket
import subprocess
import time
from bongo.store.StoreClient import StoreClient
HOST = os.environ.get("BONGO_TEST_HOST", "127.0.0.1")
PORT = int(os.environ.get("BONGO_TEST_SMTP_PORT", "25"))
STORE_PORT = int(os.environ.get("BONGO_TEST_STORE_PORT", "689"))
QUEUE_TOOL = os.environ.get(
"BONGO_TEST_QUEUE_TOOL", "/usr/bin/bongo-queuetool"
)
USER = os.environ.get("BONGO_TEST_USER", "test1")
DOMAIN = os.environ.get("BONGO_TEST_DOMAIN", "bongo.test")
PASSWORD = os.environ.get("BONGO_TEST_PASSWORD", "")
TIMEOUT = float(os.environ.get("BONGO_TEST_TIMEOUT", "30"))
ALLOW_LIVE = os.environ.get("BONGO_ALLOW_LIVE_SMTP_TEST") == "1"
TOKEN = f"smtp12-{os.getpid()}-{int(time.time())}"
TOKEN_PREFIX = b"smtp12-"
RESPONSE = re.compile(rb"^([0-9]{3})([- ])(.*)\r\n$")
QUEUE_ID = re.compile(r"^[0-9A-Fa-f]{3}-[0-9A-Fa-f]+$")
class SMTP12Error(RuntimeError):
"""Raised when a pipelined SMTP session loses ordering or state."""
def run_queue(
*arguments: str, binary: bool = False, check: bool = True
) -> subprocess.CompletedProcess:
completed = subprocess.run(
[
"sudo",
"-n",
"-u",
"bongo",
QUEUE_TOOL,
*arguments,
],
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 SMTP12Error(
f"{QUEUE_TOOL} {' '.join(arguments)} failed: "
f"{stderr.strip() or stdout.strip()}"
)
return completed
class SMTPConnection:
def __init__(self) -> None:
self.socket = socket.create_connection((HOST, PORT), timeout=TIMEOUT)
self.socket.settimeout(TIMEOUT)
self.reader = self.socket.makefile("rb")
def close(self) -> None:
try:
self.reader.close()
finally:
self.socket.close()
def response(self) -> tuple[int, list[bytes]]:
lines: list[bytes] = []
response_code: int | None = None
while True:
line = self.reader.readline(8194)
if not line:
raise SMTP12Error("connection closed while awaiting response")
if len(line) > 8192:
raise SMTP12Error("SMTP response line exceeded 8192 bytes")
match = RESPONSE.match(line)
if match is None:
raise SMTP12Error(f"malformed SMTP response: {line!r}")
code = int(match.group(1))
if response_code is None:
response_code = code
elif code != response_code:
raise SMTP12Error(
f"multiline response changed status: {line!r}"
)
lines.append(match.group(3))
if match.group(2) == b" ":
return code, lines
def require(self, expected: int, context: str) -> list[bytes]:
code, lines = self.response()
if code != expected:
raise SMTP12Error(
f"{context}: expected SMTP {expected}, got {code}: {lines!r}"
)
return lines
def command(self, command: str, expected: int) -> list[bytes]:
self.socket.sendall(command.encode("ascii") + b"\r\n")
return self.require(expected, command)
def pipeline(
self,
commands: list[str],
expected: list[int],
context: str,
) -> list[list[bytes]]:
if len(commands) != len(expected):
raise SMTP12Error("internal pipeline expectation mismatch")
wire = "".join(f"{command}\r\n" for command in commands)
self.socket.sendall(wire.encode("ascii"))
return [
self.require(code, f"{context} response {index + 1}")
for index, code in enumerate(expected)
]
def queue_token_ids() -> list[str]:
matches: list[str] = []
for line in run_queue("list").stdout.splitlines():
fields = line.split()
if not fields or QUEUE_ID.fullmatch(fields[0]) is None:
continue
message = run_queue(
"message", fields[0], binary=True, check=False
)
if message.returncode == 0 and TOKEN_PREFIX in message.stdout:
matches.append(fields[0])
return matches
def open_store() -> StoreClient:
return StoreClient(
USER,
USER,
authPassword=PASSWORD,
host=HOST,
port=STORE_PORT,
)
def store_token_documents(store: StoreClient) -> list[str]:
identifiers = [entry.uid for entry in store.List("/mail/INBOX")]
matches: list[str] = []
for identifier in identifiers:
try:
data = store.Read(identifier)
except Exception:
continue
if TOKEN_PREFIX in data:
matches.append(identifier)
return matches
def cleanup(store: StoreClient) -> None:
deadline = time.monotonic() + 20
quiet_since: float | None = None
while time.monotonic() < deadline:
found = False
for document in store_token_documents(store):
found = True
store.Delete(document)
for queue_id in queue_token_ids():
found = True
run_queue("delete", queue_id, check=False)
if found:
quiet_since = None
elif quiet_since is None:
quiet_since = time.monotonic()
elif time.monotonic() - quiet_since >= 1:
return
time.sleep(0.25)
def wait_for_delivery(store: StoreClient) -> str:
deadline = time.monotonic() + TIMEOUT
while time.monotonic() < deadline:
documents = store_token_documents(store)
if len(documents) > 1:
raise SMTP12Error(
f"pipelined message delivered more than once: {documents!r}"
)
if documents:
return documents[0]
time.sleep(0.25)
raise SMTP12Error("pipelined message did not reach the Store")
def message() -> bytes:
return (
f"From: SMTP-12 <sender@example.test>\r\n"
f"To: {USER}@{DOMAIN}\r\n"
f"Subject: SMTP-12 pipelining {TOKEN}\r\n"
f"Message-ID: <{TOKEN}@{DOMAIN}>\r\n"
f"X-Bongo-Test: {TOKEN}\r\n"
"\r\n"
f"Pipelined delivery fixture {TOKEN}.\r\n"
).encode("ascii")
def main() -> int:
if not ALLOW_LIVE:
raise SMTP12Error(
"set BONGO_ALLOW_LIVE_SMTP_TEST=1 for the live delivery test"
)
if not PASSWORD:
raise SMTP12Error("BONGO_TEST_PASSWORD is required")
store = open_store()
try:
cleanup(store)
client = SMTPConnection()
try:
client.require(220, "greeting")
capabilities = client.command(
"EHLO smtp12-client.example", 250
)
if b"PIPELINING" not in capabilities:
raise SMTP12Error(
f"EHLO did not advertise PIPELINING: {capabilities!r}"
)
client.pipeline(
[
"MAIL FROM:<sender@example.test>",
f"RCPT TO:<{USER}@{DOMAIN}>",
"RSET",
"NOOP",
],
[250, 250, 250, 250],
"ordinary",
)
client.pipeline(
[
"MAIL FROM:<sender@example.test>",
"RCPT TO:<victim@recipient.example>",
"DATA",
],
[250, 554, 503],
"rejected recipient",
)
client.pipeline(
["RSET", "NOOP"],
[250, 250],
"rejected recipient recovery",
)
client.pipeline(
[
"MAIL FROM:<sender@example.test>",
"BOGUS",
f"RCPT TO:<{USER}@{DOMAIN}>",
"RSET",
],
[250, 500, 250, 250],
"unknown command",
)
client.pipeline(
[
"MAIL FROM:<sender@example.test>",
"RCPT TO:<victim@recipient.example>",
f"RCPT TO:<{USER}@{DOMAIN}>",
"DATA",
],
[250, 554, 250, 354],
"mixed recipients and DATA",
)
client.socket.sendall(
message()
+ b".\r\n"
+ b"NOOP\r\nRSET\r\nQUIT\r\n"
)
client.require(250, "message completion")
client.require(250, "post-DATA pipelined NOOP")
client.require(250, "post-DATA pipelined RSET")
client.require(221, "post-DATA pipelined QUIT")
client.socket.settimeout(2)
if client.reader.read(1) != b"":
raise SMTP12Error("server did not close after pipelined QUIT")
finally:
client.close()
document = wait_for_delivery(store)
store.Delete(document)
cleanup(store)
if store_token_documents(store) or queue_token_ids():
raise SMTP12Error("test content remains after cleanup")
finally:
try:
cleanup(store)
finally:
store.Quit()
print(
"SMTP-12 PASS advertised=yes ordinary-order=250/250/250/250 "
"rejection-order=250/554/503/250/250 "
"unknown-command=500/recovered mixed-recipients=554/250 "
"data-sync=354 post-data=250/250/250/221 "
"duplicate=no queue-clean=yes store-clean=yes"
)
return 0
if __name__ == "__main__":
try:
raise SystemExit(main())
except (OSError, SMTP12Error, ValueError) as error:
print(f"SMTP-12 FAIL: {type(error).__name__}: {error}")
raise SystemExit(1)