Files
bongo/contrib/testing/smtp-basic-check.py
2026-07-24 06:30:11 +02:00

142 lines
4.8 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
"""Exercise the non-delivery SMTP command surface on port 25."""
from __future__ import annotations
import os
import re
import socket
HOST = os.environ.get("BONGO_TEST_HOST", "127.0.0.1")
PORT = int(os.environ.get("BONGO_TEST_SMTP_PORT", "25"))
TIMEOUT = float(os.environ.get("BONGO_TEST_TIMEOUT", "10"))
HELO_NAME = os.environ.get("BONGO_TEST_HELO", "client.bongo.test")
EXISTING_USER = os.environ.get("BONGO_TEST_USER", "test1")
MISSING_USER = os.environ.get(
"BONGO_TEST_MISSING_USER", "smtp01-definitely-missing"
)
RESPONSE = re.compile(rb"^([0-9]{3})([- ])(.*)\r\n$")
class SMTPCheckError(RuntimeError):
"""Raised when the live SMTP peer violates the tested contract."""
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, expected: int) -> list[bytes]:
lines: list[bytes] = []
expected_code = f"{expected:03d}".encode("ascii")
while True:
line = self.reader.readline(8194)
if not line:
raise SMTPCheckError(
f"connection closed while waiting for SMTP {expected}"
)
if len(line) > 8192:
raise SMTPCheckError("SMTP response line exceeded 8192 bytes")
match = RESPONSE.match(line)
if match is None:
raise SMTPCheckError(f"malformed SMTP response: {line!r}")
if match.group(1) != expected_code:
raise SMTPCheckError(
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]:
payload = command.encode("ascii") + b"\r\n"
self.socket.sendall(payload)
return self.response(expected)
def contains_capability(lines: list[bytes], capability: bytes) -> bool:
wanted = capability.upper()
return any(
line.split(None, 1)[0].upper() == wanted
for line in lines[1:]
if line
)
def check_session(extended: bool) -> tuple[int, int]:
client = SMTPConnection()
try:
greeting = client.response(220)
if not greeting or not greeting[0].strip():
raise SMTPCheckError("SMTP greeting did not identify the server")
if extended:
hello = client.command(f"EHLO {HELO_NAME}", 250)
if len(hello) < 2:
raise SMTPCheckError("EHLO did not return a multiline response")
if not contains_capability(hello, b"HELP"):
raise SMTPCheckError("EHLO did not advertise HELP")
if contains_capability(hello, b"VRFY"):
raise SMTPCheckError(
"EHLO advertised VRFY while the policy disables it"
)
else:
hello = client.command(f"HELO {HELO_NAME}", 250)
if len(hello) != 1:
raise SMTPCheckError("HELO unexpectedly returned extensions")
client.command("NOOP smtp-01", 250)
client.command("RSET", 250)
help_lines = client.command("HELP", 214)
if not any(b"HELO" in line and b"QUIT" in line for line in help_lines):
raise SMTPCheckError("HELP omitted the basic SMTP commands")
existing = client.command(f"VRFY {EXISTING_USER}", 252)
missing = client.command(f"VRFY {MISSING_USER}", 252)
if existing != missing:
raise SMTPCheckError(
"disabled VRFY disclosed whether a local user exists"
)
client.command("QUIT", 221)
client.socket.settimeout(2)
if client.reader.read(1) != b"":
raise SMTPCheckError("server did not close the connection after QUIT")
return len(greeting), len(hello)
finally:
client.close()
def main() -> int:
helo_greeting, helo_lines = check_session(False)
ehlo_greeting, ehlo_lines = check_session(True)
print(
"SMTP-01 PASS "
f"host={HOST} port={PORT} greeting=yes "
f"helo-lines={helo_lines} ehlo-lines={ehlo_lines} "
"noop=yes rset=yes help=yes vrfy=252-no-disclosure quit=yes "
f"sessions={helo_greeting + ehlo_greeting}"
)
return 0
if __name__ == "__main__":
try:
raise SystemExit(main())
except (OSError, ValueError, SMTPCheckError) as error:
print(f"SMTP-01 FAIL: {error}")
raise SystemExit(1)