Files
bongo/contrib/testing/protocol-smoke.py
T
Mario Fetka af1e7e3be2
Debian Trixie package bundle / packages (push) Successful in 22m14s
Keep ManageSieve listener alive across sessions
2026-07-28 19:46:15 +02:00

341 lines
12 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 a local Bongo SMTP/IMAP/POP TLS delivery path."""
from __future__ import annotations
import argparse
import base64
from email.message import EmailMessage
import imaplib
import os
import poplib
import smtplib
import socket
import ssl
import sys
import time
import urllib.request
def environment(name: str, default: str = "") -> str:
return os.environ.get(name, default)
HOST = environment("BONGO_TEST_HOST", "127.0.0.1")
USER1 = environment("BONGO_TEST_USER1", "test1")
USER2 = environment("BONGO_TEST_USER2", "test2")
DOMAIN = environment("BONGO_TEST_DOMAIN", "bongo.test")
PASSWORD = environment("BONGO_TEST_PASSWORD")
PORTS = {
"pop3": int(environment("BONGO_TEST_POP3_PORT", "110")),
"imap": int(environment("BONGO_TEST_IMAP_PORT", "143")),
"smtps": int(environment("BONGO_TEST_SMTPS_PORT", "465")),
"submission": int(environment("BONGO_TEST_SUBMISSION_PORT", "587")),
"sieve": int(environment("BONGO_TEST_SIEVE_PORT", "4190")),
"imaps": int(environment("BONGO_TEST_IMAPS_PORT", "993")),
"pop3s": int(environment("BONGO_TEST_POP3S_PORT", "995")),
}
TOKEN = environment("BONGO_TEST_TOKEN", f"bongo-smoke-{int(time.time())}")
TLS = ssl.create_default_context()
TLS.check_hostname = False
TLS.verify_mode = ssl.CERT_NONE
def sieve_quote(value: str) -> str:
return '"' + value.replace("\\", "\\\\").replace('"', '\\"') + '"'
class ManageSieveClient:
def __init__(self) -> None:
self.socket = socket.create_connection(
(HOST, PORTS["sieve"]), timeout=15)
self.stream = self.socket.makefile("rb")
self.read_response("greeting")
def close(self) -> None:
try:
self.stream.close()
finally:
self.socket.close()
def send(self, data: bytes) -> None:
self.socket.sendall(data)
def line(self) -> bytes:
line = self.stream.readline(65537)
if not line or len(line) > 65536 or not line.endswith(b"\n"):
raise RuntimeError("ManageSieve connection ended mid-response")
return line
def read_response(self, operation: str) -> list[str]:
lines: list[str] = []
while True:
try:
decoded = self.line().decode(
"utf-8", "strict").rstrip("\r\n")
except TimeoutError as error:
raise RuntimeError(
f"ManageSieve {operation} timed out") from error
lines.append(decoded)
upper = decoded.upper()
if upper == "OK" or upper.startswith("OK ") or \
upper == "NO" or upper.startswith("NO ") or \
upper == "BYE" or upper.startswith("BYE "):
if not upper.startswith("OK"):
raise RuntimeError(
f"ManageSieve {operation} failed: {decoded}")
return lines
def command(self, operation: str, command: str) -> list[str]:
self.send(command.encode("utf-8") + b"\r\n")
return self.read_response(operation)
def starttls(self) -> None:
self.command("STARTTLS", "STARTTLS")
self.stream.close()
self.socket = TLS.wrap_socket(self.socket, server_hostname=HOST)
self.stream = self.socket.makefile("rb")
def literal_command(
self, operation: str, command: str, payload: bytes) -> list[str]:
self.send(
f"{command} {{{len(payload)}+}}\r\n".encode("utf-8") + payload)
return self.read_response(operation)
def get_script(self, name: str) -> bytes:
self.send(f"GETSCRIPT {sieve_quote(name)}\r\n".encode("utf-8"))
marker = self.line().decode("ascii", "strict").strip()
if not marker.startswith("{") or not marker.endswith("}"):
raise RuntimeError(f"invalid GETSCRIPT literal marker: {marker}")
length = int(marker[1:-1])
payload = self.stream.read(length)
if len(payload) != length or self.stream.read(2) != b"\r\n":
raise RuntimeError("truncated GETSCRIPT literal")
self.read_response("GETSCRIPT")
return payload
def wait_for_listeners(timeout: float = 30.0) -> str:
deadline = time.monotonic() + timeout
pending = set(PORTS.values())
while pending and time.monotonic() < deadline:
for port in tuple(pending):
try:
with socket.create_connection((HOST, port), timeout=0.25):
pending.remove(port)
except OSError:
pass
if pending:
time.sleep(0.25)
if pending:
raise RuntimeError(f"listeners did not become ready: {sorted(pending)}")
return "all protocol listeners accepted a connection"
def report(name, operation) -> bool:
try:
detail = operation()
print(f"PASS {name}: {detail}")
return True
except Exception as error: # integration test: report every failed leg
print(f"FAIL {name}: {type(error).__name__}: {error}")
return False
def web() -> str:
url = environment("BONGO_TEST_WEB_URL")
request = urllib.request.Request(url, method="GET")
with urllib.request.urlopen(request, timeout=10) as response:
return f"HTTP {response.status}, {response.geturl()}"
def smtp_submit() -> str:
message = EmailMessage()
message["From"] = f"{USER1}@{DOMAIN}"
message["To"] = f"{USER2}@{DOMAIN}"
message["Subject"] = TOKEN
message.set_content(f"Bongo local protocol smoke test {TOKEN}\n")
with smtplib.SMTP(HOST, PORTS["submission"], timeout=15) as client:
code, _ = client.ehlo()
if code != 250 or "starttls" not in client.esmtp_features:
raise RuntimeError("submission does not advertise STARTTLS")
client.starttls(context=TLS)
client.ehlo()
client.login(USER1, PASSWORD)
refused = client.send_message(message)
if refused:
raise RuntimeError(f"recipients refused: {refused}")
return TOKEN
def managesieve() -> str:
script_name = f"{TOKEN[:48]} filter"
renamed = f"{TOKEN[:48]} renamed"
script = (
'require ["fileinto"];\r\n'
'if header :contains "Subject" "Bongo" { keep; }\r\n'
).encode("utf-8")
auth = base64.b64encode(
b"\0" + USER1.encode("utf-8") + b"\0" + PASSWORD.encode("utf-8")
).decode("ascii")
client = ManageSieveClient()
try:
client.starttls()
capabilities = client.command("CAPABILITY", "CAPABILITY")
if not any('"SASL"' in line and "PLAIN" in line
for line in capabilities):
raise RuntimeError("SASL PLAIN not advertised after STARTTLS")
client.command(
"AUTHENTICATE",
f'AUTHENTICATE "PLAIN" "{auth}"')
client.literal_command(
"CHECKSCRIPT", "CHECKSCRIPT", script)
client.literal_command(
"PUTSCRIPT", f"PUTSCRIPT {sieve_quote(script_name)}", script)
listing = client.command("LISTSCRIPTS", "LISTSCRIPTS")
if not any(sieve_quote(script_name) in line for line in listing):
raise RuntimeError("uploaded script missing from LISTSCRIPTS")
client.command(
"SETACTIVE", f"SETACTIVE {sieve_quote(script_name)}")
if client.get_script(script_name) != script:
raise RuntimeError("GETSCRIPT returned different script bytes")
client.command(
"RENAMESCRIPT",
f"RENAMESCRIPT {sieve_quote(script_name)} {sieve_quote(renamed)}")
listing = client.command("LISTSCRIPTS", "LISTSCRIPTS")
if not any(sieve_quote(renamed) in line and "ACTIVE" in line
for line in listing):
raise RuntimeError("renamed active script missing")
client.command("SETACTIVE", 'SETACTIVE ""')
client.command(
"DELETESCRIPT", f"DELETESCRIPT {sieve_quote(renamed)}")
client.command("LOGOUT", "LOGOUT")
finally:
client.close()
return "upload, activate, retrieve, rename and delete passed"
def managesieve_repeated() -> str:
first = managesieve()
second = managesieve()
return f"two consecutive sessions passed ({first}; {second})"
def smtp_implicit() -> str:
with smtplib.SMTP_SSL(HOST, PORTS["smtps"], timeout=15,
context=TLS) as client:
code, _ = client.ehlo()
if code != 250:
raise RuntimeError(f"EHLO returned {code}")
client.login(USER1, PASSWORD)
return "TLS and authentication accepted"
def wait_for_imap() -> str:
deadline = time.monotonic() + 20
last = None
while time.monotonic() < deadline:
try:
with imaplib.IMAP4_SSL(HOST, PORTS["imaps"], ssl_context=TLS,
timeout=10) as client:
client.login(USER2, PASSWORD)
status, _ = client.select("INBOX", readonly=True)
if status != "OK":
raise RuntimeError("cannot select INBOX")
status, data = client.search(None, "SUBJECT", f'"{TOKEN}"')
if status == "OK" and data and data[0].split():
identifier = data[0].split()[-1]
status, _ = client.fetch(
identifier, "(BODY.PEEK[HEADER.FIELDS (SUBJECT)])")
if status != "OK":
raise RuntimeError("message fetch failed")
return f"message {identifier.decode()} found"
last = data
except Exception as error: # delivery can take a few seconds
last = error
time.sleep(1)
raise RuntimeError(f"message did not arrive: {last}")
def imap_starttls() -> str:
with imaplib.IMAP4(HOST, PORTS["imap"], timeout=15) as client:
capabilities = set(client.capabilities)
if "STARTTLS" not in capabilities and b"STARTTLS" not in capabilities:
raise RuntimeError(f"STARTTLS not advertised: {capabilities!r}")
client.starttls(ssl_context=TLS)
client.login(USER2, PASSWORD)
status, data = client.select("INBOX", readonly=True)
if status != "OK":
raise RuntimeError("cannot select INBOX")
return f"INBOX contains {data[0].decode()} message(s)"
def pop3_starttls() -> str:
client = poplib.POP3(HOST, PORTS["pop3"], timeout=15)
try:
capabilities = client.capa()
if "STLS" not in capabilities and b"STLS" not in capabilities:
raise RuntimeError(f"STLS not advertised: {capabilities!r}")
client.stls(context=TLS)
client.user(USER2)
client.pass_(PASSWORD)
count, size = client.stat()
return f"maildrop has {count} message(s), {size} bytes"
finally:
try:
client.quit()
except Exception:
client.close()
def pop3_implicit() -> str:
client = poplib.POP3_SSL(HOST, PORTS["pop3s"], timeout=15, context=TLS)
try:
client.user(USER2)
client.pass_(PASSWORD)
count, size = client.stat()
return f"maildrop has {count} message(s), {size} bytes"
finally:
try:
client.quit()
except Exception:
client.close()
def main() -> int:
parser = argparse.ArgumentParser()
parser.add_argument("--wait-only", action="store_true")
parser.add_argument("--sieve-only", action="store_true")
arguments = parser.parse_args()
if arguments.wait_only:
return 0 if report("listener readiness", wait_for_listeners) else 1
if not PASSWORD:
parser.error("BONGO_TEST_PASSWORD must be set")
if arguments.sieve_only:
return 0 if report(
"ManageSieve NMAP Store", managesieve_repeated) else 1
checks = [
("listener readiness", wait_for_listeners),
("ManageSieve NMAP Store", managesieve_repeated),
("SMTP submission STARTTLS", smtp_submit),
("SMTPS submission", smtp_implicit),
("IMAPS delivery", wait_for_imap),
("IMAP STARTTLS", imap_starttls),
("POP3 STLS", pop3_starttls),
("POP3S", pop3_implicit),
]
if environment("BONGO_TEST_WEB_URL"):
checks.append(("Web", web))
success = True
for name, operation in checks:
success = report(name, operation) and success
return 0 if success else 1
if __name__ == "__main__":
sys.exit(main())