Files
bongo/contrib/testing/cyrus-fixture.py
T
Mario Fetka 92e0ba117d
Debian Trixie package bundle / packages (push) Failing after 14m43s
Bound external collector queue growth
2026-07-23 22:15:21 +02:00

544 lines
20 KiB
Python
Executable File

#!/usr/bin/env python3
# /****************************************************************************
# * <Novell-copyright>
# * Copyright (c) 2001 Novell, Inc. All Rights Reserved.
# *
# * This program is free software; you can redistribute it and/or
# * modify it under the terms of version 2 of the GNU General Public License
# * as published by the Free Software Foundation.
# *
# * This program is distributed in the hope that it will be useful,
# * but WITHOUT ANY WARRANTY; without even the implied warranty of
# * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# * GNU General Public License for more details.
# *
# * You should have received a copy of the GNU General Public License
# * along with this program; if not, contact Novell, Inc.
# *
# * To contact Novell about this file by physical or electronic mail, you
# * may find current contact information at www.novell.com.
# * </Novell-copyright>
# ****************************************************************************/
"""Manage a disposable loopback-only Cyrus IMAP/POP3/LMTP test server."""
from __future__ import annotations
import argparse
from email import policy
from email.message import EmailMessage
import grp
import imaplib
import os
from pathlib import Path
import pwd
import shutil
import signal
import smtplib
import socket
import subprocess
import sys
import time
DEFAULT_ROOT = Path("/tmp/bongo-cyrus-fixture")
DEFAULT_PASSWORD = "BongoCollector-Test-2026"
FIXTURE_DOMAIN = "cyrus.bongo.test"
SOURCE_ACCOUNTS = (
("test1-pop", "pop3", "test1", "INBOX"),
("test1-imap", "imap", "test1", "Cyrus IMAP"),
("test2-pop", "pop3", "test2", "INBOX"),
("test2-imap", "imap", "test2", "Cyrus IMAP"),
)
SOURCE_USERS = tuple(account[0] for account in SOURCE_ACCOUNTS)
PORTS = {"imap": 18143, "pop3": 18110, "lmtp": 18024}
GTUBE = (
"XJS*C4JDBQADN1.NSBN3*2IDNEN*GTUBE-STANDARD-ANTI-UBE-TEST-EMAIL*C.34X"
)
# Keep the complete EICAR signature out of the source checkout. Some host
# scanners quarantine a repository merely because the harmless test string is
# present in a source file. The generated attachment contains the canonical
# 68-byte signature expected by ClamAV.
EICAR = (
"X5O!P%@AP[4\\PZX54(P^)7CC)7}$"
"EICAR-STANDARD-ANTIVIRUS-TEST-FILE!$H+H*"
)
BINARIES = {
"master": "/usr/libexec/cyrusmaster",
"imap": "/usr/libexec/imapd",
"pop3": "/usr/libexec/pop3d",
"lmtp": "/usr/libexec/lmtpd",
"recover": "/usr/sbin/ctl_cyrusdb",
"saslpasswd": "/usr/bin/saslpasswd2",
"certtool": "/usr/bin/certtool",
}
def checked_root(value: str) -> Path:
root = Path(value).resolve()
if root == Path("/") or not str(root).startswith("/tmp/bongo-cyrus-"):
raise argparse.ArgumentTypeError(
"fixture root must be below /tmp and start with bongo-cyrus-")
return root
def require_root() -> None:
if os.geteuid() != 0:
raise SystemExit("run this command as root (or through sudo)")
def paths(root: Path) -> dict[str, Path]:
return {
"config": root / "config",
"spool": root / "spool",
"run": root / "run",
"imapd": root / "imapd.conf",
"cyrus": root / "cyrus.conf",
"sasldb": root / "sasldb2",
"pid": root / "run" / "master.pid",
"ready": root / "run" / "master.ready",
"log": root / "run" / "master.log",
"certificate": root / "tls" / "server.crt",
"private_key": root / "tls" / "server.key",
"certificate_template": root / "tls" / "server.tmpl",
"ca_certificate": root / "tls" / "ca.crt",
"ca_private_key": root / "tls" / "ca.key",
"ca_template": root / "tls" / "ca.tmpl",
}
def write_configuration(root: Path) -> None:
values = paths(root)
imapd = f"""# Generated disposable Bongo test fixture.
configdirectory: {values['config']}
partition-default: {values['spool']}
defaultpartition: default
admins: testadmin
servername: {FIXTURE_DOMAIN}
allowplaintext: yes
virtdomains: off
altnamespace: yes
sasl_mech_list: PLAIN LOGIN
sasl_pwcheck_method: auxprop
sasl_auxprop_plugin: sasldb
sasl_sasldb_path: {values['sasldb']}
sasl_default_realm: {FIXTURE_DOMAIN}
tls_server_cert: {values['certificate']}
tls_server_key: {values['private_key']}
tls_versions: tls1_2 tls1_3
master_pid_file: {values['pid']}
master_ready_file: {values['ready']}
statuscache: off
duplicate_db: skiplist
mboxlist_db: skiplist
subscription_db: flat
seenstate_db: flat
"""
config = f"""# Generated disposable Bongo test fixture.
START {{
recover cmd=\"{BINARIES['recover']} -C {values['imapd']} -r\"
}}
SERVICES {{
imap cmd=\"{BINARIES['imap']} -C {values['imapd']}\" listen=\"127.0.0.1:{PORTS['imap']}\" prefork=0
pop3 cmd=\"{BINARIES['pop3']} -C {values['imapd']}\" listen=\"127.0.0.1:{PORTS['pop3']}\" prefork=0
lmtp cmd=\"{BINARIES['lmtp']} -C {values['imapd']} -a\" listen=\"127.0.0.1:{PORTS['lmtp']}\" prefork=0
}}
EVENTS {{
}}
"""
values["imapd"].write_text(imapd, encoding="utf-8")
values["cyrus"].write_text(config, encoding="utf-8")
def set_password(database: Path, username: str, password: str) -> None:
subprocess.run(
[BINARIES["saslpasswd"], "-p", "-c", "-f", str(database),
"-u", FIXTURE_DOMAIN, username],
input=(password + "\n").encode("utf-8"), check=True)
def generate_certificate(root: Path) -> None:
values = paths(root)
ca_template = """cn = Bongo Cyrus Fixture CA
expiration_days = 2
ca
cert_signing_key
crl_signing_key
"""
template = f"""cn = {FIXTURE_DOMAIN}
dns_name = {FIXTURE_DOMAIN}
dns_name = localhost
ip_address = 127.0.0.1
expiration_days = 2
signing_key
encryption_key
tls_www_server
"""
values["ca_template"].write_text(ca_template, encoding="ascii")
values["certificate_template"].write_text(template, encoding="ascii")
subprocess.run(
[BINARIES["certtool"], "--generate-privkey", "--sec-param", "high",
"--outfile", str(values["ca_private_key"])], check=True,
stdout=subprocess.DEVNULL)
subprocess.run(
[BINARIES["certtool"], "--generate-self-signed",
"--load-privkey", str(values["ca_private_key"]),
"--template", str(values["ca_template"]),
"--outfile", str(values["ca_certificate"])], check=True,
stdout=subprocess.DEVNULL)
subprocess.run(
[BINARIES["certtool"], "--generate-privkey", "--sec-param", "high",
"--outfile", str(values["private_key"])], check=True,
stdout=subprocess.DEVNULL)
subprocess.run(
[BINARIES["certtool"], "--generate-certificate",
"--load-privkey", str(values["private_key"]),
"--load-ca-certificate", str(values["ca_certificate"]),
"--load-ca-privkey", str(values["ca_private_key"]),
"--template", str(values["certificate_template"]),
"--outfile", str(values["certificate"])], check=True,
stdout=subprocess.DEVNULL)
def prepare(root: Path, password: str) -> None:
require_root()
missing = [path for path in BINARIES.values() if not Path(path).is_file()]
if missing:
raise SystemExit("missing Cyrus tools: " + ", ".join(missing))
values = paths(root)
for directory in (root, values["config"], values["spool"], values["run"],
root / "tls",
values["config"] / "socket", values["config"] / "proc",
values["config"] / "log", values["config"] / "msg"):
directory.mkdir(parents=True, exist_ok=True)
write_configuration(root)
generate_certificate(root)
set_password(values["sasldb"], "testadmin", password)
for username in SOURCE_USERS:
set_password(values["sasldb"], username, password)
cyrus = pwd.getpwnam("cyrus")
mail_gid = grp.getgrnam("mail").gr_gid
for directory, subdirs, filenames in os.walk(root):
os.chown(directory, cyrus.pw_uid, mail_gid)
os.chmod(directory, 0o750)
for filename in filenames:
target = Path(directory) / filename
os.chown(target, cyrus.pw_uid, mail_gid)
os.chmod(target, 0o640)
# Certificates are public inputs consumed by an unprivileged Collector
# test. Both private keys remain cyrus:mail 0640.
os.chmod(root, 0o755)
os.chmod(root / "tls", 0o755)
os.chmod(values["certificate"], 0o644)
os.chmod(values["ca_certificate"], 0o644)
print(f"prepared Cyrus fixture in {root}")
def read_pid(root: Path) -> int | None:
try:
pid = int(paths(root)["pid"].read_text(encoding="ascii").strip())
os.kill(pid, 0)
return pid
except (FileNotFoundError, ProcessLookupError, PermissionError, ValueError):
return None
def wait_for_port(port: int, timeout: float = 10.0) -> None:
deadline = time.monotonic() + timeout
while time.monotonic() < deadline:
try:
with socket.create_connection(("127.0.0.1", port), timeout=0.25):
return
except OSError:
time.sleep(0.1)
raise RuntimeError(f"Cyrus did not open 127.0.0.1:{port}")
def ensure_mailboxes(password: str) -> None:
client = imaplib.IMAP4("127.0.0.1", PORTS["imap"])
try:
client.login("testadmin", password)
for username in SOURCE_USERS:
# altnamespace uses '/' as the hierarchy delimiter. A literal
# "user.name" would create a shared top-level mailbox and LMTP
# would correctly reject delivery to the intended user.
mailbox = f"user/{username}"
status, _ = client.create(mailbox)
if status not in ("OK", "NO"):
raise RuntimeError(f"could not create Cyrus mailbox {mailbox}")
client.setacl(mailbox, username, "lrswipkxtecdan")
finally:
try:
client.logout()
except OSError:
pass
def start(root: Path, password: str) -> None:
require_root()
if read_pid(root):
print("Cyrus fixture is already running")
return
prepare(root, password)
values = paths(root)
subprocess.run(
[BINARIES["master"], "-d", "-C", str(values["imapd"]),
"-M", str(values["cyrus"]), "-p", str(values["pid"]),
"-L", str(values["log"])], check=True)
for port in PORTS.values():
wait_for_port(port)
ensure_mailboxes(password)
print("Cyrus fixture ready: IMAP 18143, POP3 18110, LMTP 18024")
print(f"TLS trust anchor: {values['ca_certificate']}")
def stop(root: Path) -> None:
require_root()
pid = read_pid(root)
if pid is None:
print("Cyrus fixture is not running")
return
os.kill(pid, signal.SIGTERM)
deadline = time.monotonic() + 10
while time.monotonic() < deadline:
try:
os.kill(pid, 0)
except ProcessLookupError:
break
time.sleep(0.1)
else:
raise RuntimeError("Cyrus fixture did not stop")
print("Cyrus fixture stopped")
def inject(filename: str, sender: str, recipient: str) -> None:
if filename == "-":
payload = sys.stdin.buffer.read()
else:
payload = Path(filename).read_bytes()
if not payload:
message = EmailMessage()
message["From"] = sender
message["To"] = recipient
message["Subject"] = "Bongo collector fixture"
message.set_content("Bongo collector fixture message")
payload = message.as_bytes()
with smtplib.LMTP("127.0.0.1", PORTS["lmtp"], timeout=5) as client:
refused = client.sendmail(sender, [recipient], payload)
if refused:
raise RuntimeError(f"LMTP refused recipient: {refused}")
print(f"injected {len(payload)} bytes for {recipient}")
def fixture_message(username: str, profile: str, number: int,
message_size: int) -> EmailMessage:
recipient = f"{username}@{FIXTURE_DOMAIN}"
message = EmailMessage(policy=policy.SMTP)
message["From"] = "fixture-sender@bongo.test"
message["To"] = recipient
message["Subject"] = f"Bongo {profile} test {username} {number}"
message["Message-ID"] = (
f"<collector-{profile}-{username}-{number}@bongo.test>")
message["X-Bongo-Collector-Test"] = f"{profile}-{username}-{number}"
message["X-Bongo-Test-Profile"] = profile
if profile == "normal":
message.set_content(
f"Cyrus collector fixture message {number} for {username}.\n")
elif profile == "gtube":
message.set_content(
"This is the standard SpamAssassin GTUBE test message.\n\n"
f"{GTUBE}\n")
elif profile == "eicar":
message.set_content(
"This message contains the harmless EICAR antivirus test file.\n")
message.add_attachment(
(EICAR + "\n").encode("ascii"), maintype="application",
subtype="octet-stream", filename="eicar.com")
elif profile == "flood":
prefix = (
f"Bounded Bongo quota flood message {number} for {username}.\n"
).encode("ascii")
if message_size < len(prefix):
raise ValueError("flood message size is too small")
payload = prefix + b"Q" * (message_size - len(prefix))
message.set_content(payload.decode("ascii"))
else:
raise ValueError(f"unsupported seed profile: {profile}")
return message
def deliver_message(message: EmailMessage) -> None:
recipient = str(message["To"])
with smtplib.LMTP("127.0.0.1", PORTS["lmtp"], timeout=5) as client:
refused = client.send_message(
message, from_addr=str(message["From"]), to_addrs=[recipient])
if refused:
raise RuntimeError(f"LMTP refused recipient: {refused}")
def seed_profile(profile: str, count: int, message_size: int,
users: tuple[str, ...] = SOURCE_USERS) -> None:
maximum = 256 if profile == "flood" else 100
if count < 1 or count > maximum:
raise ValueError(f"{profile} count must be between 1 and {maximum}")
if profile == "flood" and not 4096 <= message_size <= 4 * 1024 * 1024:
raise ValueError("flood message size must be between 4 KiB and 4 MiB")
invalid_users = sorted(set(users) - set(SOURCE_USERS))
if invalid_users:
raise ValueError(
"unknown Cyrus source user(s): " + ", ".join(invalid_users)
)
if not users:
raise ValueError("at least one Cyrus source user is required")
for username in users:
for number in range(1, count + 1):
deliver_message(fixture_message(
username, profile, number, message_size))
print(
f"seeded {count} {profile} message(s) in "
f"{', '.join(users)}"
)
def seed_suite(normal_count: int, flood_count: int, flood_size: int) -> None:
seed_profile("normal", normal_count, flood_size)
seed_profile("gtube", 1, flood_size)
seed_profile("eicar", 1, flood_size)
seed_profile("flood", flood_count, flood_size)
total = normal_count + flood_count + 2
print(f"seeded bounded security/quota suite: {total} messages per mailbox, "
f"{total * len(SOURCE_USERS)} total")
def verify_collector(root: Path, probe: str, password: str, count: int) -> None:
executable = Path(probe).resolve()
if not executable.is_file() or not os.access(executable, os.X_OK):
raise ValueError(f"Collector transport probe is not executable: {executable}")
seed_profile("normal", count, 128 * 1024)
environment = os.environ.copy()
environment["BONGO_COLLECTOR_CAINFO"] = str(paths(root)["ca_certificate"])
for username, protocol, _owner, _folder in SOURCE_ACCOUNTS:
port = PORTS[protocol]
marker = f"X-Bongo-Collector-Test: normal-{username}-"
subprocess.run(
[str(executable), protocol, str(port), username, password,
marker, str(count)],
env=environment, check=True)
print(f"verified Collector {protocol.upper()} fetch and deletion "
f"for {count} messages")
def status(root: Path) -> None:
pid = read_pid(root)
ready = []
for port in PORTS.values():
try:
with socket.create_connection(("127.0.0.1", port), timeout=0.25):
ready.append(True)
except OSError:
ready.append(False)
if pid:
print(f"pid: {pid}")
elif any(ready):
print("pid: unavailable (fixture ports are ready)")
else:
print("pid: stopped")
for (name, port), is_ready in zip(PORTS.items(), ready):
state = "ready" if is_ready else "closed"
print(f"{name}: 127.0.0.1:{port} {state}")
def mailbox_status(password: str) -> None:
for username, protocol, owner, folder in SOURCE_ACCOUNTS:
client = imaplib.IMAP4("127.0.0.1", PORTS["imap"])
try:
client.login(username, password)
result, values = client.status("INBOX", "(MESSAGES)")
if result != "OK" or not values or values[0] is None:
raise RuntimeError(f"could not inspect Cyrus mailbox for {username}")
text = values[0].decode("ascii", "strict")
count = int(text.rsplit(" ", 1)[1].rstrip(")"))
print(f"{username}: {count} message(s) via {protocol.upper()} -> "
f"{owner}/{folder}")
finally:
try:
client.logout()
except OSError:
pass
def reset(root: Path) -> None:
require_root()
stop(root)
if root.exists():
shutil.rmtree(root)
print(f"removed {root}")
def parser() -> argparse.ArgumentParser:
result = argparse.ArgumentParser(description=__doc__)
result.add_argument("--root", type=checked_root, default=DEFAULT_ROOT)
result.add_argument("--password", default=DEFAULT_PASSWORD)
commands = result.add_subparsers(dest="command", required=True)
for name in ("prepare", "start", "stop", "status", "mailbox-status",
"reset"):
commands.add_parser(name)
delivery = commands.add_parser("inject")
delivery.add_argument("file", help="RFC 5322 message file or - for stdin")
delivery.add_argument("--sender", default="sender@bongo.test")
delivery.add_argument(
"--recipient", default=f"test1-pop@{FIXTURE_DOMAIN}")
seeding = commands.add_parser("seed")
seeding.add_argument(
"--profile", choices=("normal", "gtube", "eicar", "flood"),
default="normal")
seeding.add_argument("--count", type=int, default=3)
seeding.add_argument(
"--message-size", type=int, default=128 * 1024,
help="approximate flood body size in bytes (default: 131072)")
seeding.add_argument(
"--user", action="append", choices=SOURCE_USERS,
help="seed only this source user (repeat for multiple users)")
suite = commands.add_parser("seed-suite")
suite.add_argument("--normal-count", type=int, default=3)
suite.add_argument("--flood-count", type=int, default=32)
suite.add_argument("--flood-size", type=int, default=128 * 1024)
verification = commands.add_parser("verify-collector")
verification.add_argument("--probe", required=True)
verification.add_argument("--count", type=int, default=3)
return result
def main() -> int:
args = parser().parse_args()
if args.command == "prepare":
prepare(args.root, args.password)
elif args.command == "start":
start(args.root, args.password)
elif args.command == "stop":
stop(args.root)
elif args.command == "status":
status(args.root)
elif args.command == "mailbox-status":
mailbox_status(args.password)
elif args.command == "reset":
reset(args.root)
elif args.command == "seed":
seed_profile(
args.profile,
args.count,
args.message_size,
tuple(args.user) if args.user else SOURCE_USERS,
)
elif args.command == "seed-suite":
seed_suite(args.normal_count, args.flood_count, args.flood_size)
elif args.command == "verify-collector":
verify_collector(args.root, args.probe, args.password, args.count)
else:
inject(args.file, args.sender, args.recipient)
return 0
if __name__ == "__main__":
raise SystemExit(main())