544 lines
20 KiB
Python
Executable File
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())
|