Test SMTP size and disk-space boundaries

This commit is contained in:
Mario Fetka
2026-07-27 13:05:00 +02:00
parent 3a1dec8e59
commit b2f1efc73d
5 changed files with 656 additions and 1 deletions
+21
View File
@@ -23,6 +23,13 @@ one-off files in `/tmp`:
domain. It also covers a forged local envelope sender, the null reverse
path, and historical source-route, percent-hack, and bang-path recipient
forms without committing a message.
- `smtp-size-disk-check.sh` runs the `smtp-size-check.py` boundary probe with
a temporary 1024-byte message limit, verifies the advertised `SIZE`, exact
acceptance, declared and undeclared over-limit rejection, Store delivery,
and cleanup, then mounts the same isolated 8-MiB full-spool fixture used by
the Queue tests. Both a predeclared size and a `MAIL FROM` without `SIZE`
must receive `452 4.3.1`. Its trap restores the original SMTP document,
spool mount, service, Queue, and Store state.
- `smtp-submission-auth-check.py` exercises port 587 STARTTLS and port 465
implicit TLS directly, including the shared GSASL PLAIN and LOGIN
mechanisms, pre-TLS AUTH rejection, invalid credentials, and repeated
@@ -296,6 +303,20 @@ It requires passwordless permission for Bongo service start/stop/restart,
`mount`, `umount`, and `stat` on the dedicated test spool. Run it only on a
disposable test installation.
The SMTP size and disk-space check uses the same mount permissions and also
requires the retained disposable local account:
```sh
export BONGO_ALLOW_LIVE_SMTP_TEST=1
export BONGO_TEST_PASSWORD='a-disposable-password-of-at-least-12-characters'
./contrib/testing/smtp-size-disk-check.sh
```
It refuses to hide a non-empty Queue spool. The successful exact-limit
message and any uniquely marked leftovers from an interrupted run are removed
from Queue and Store before the original unlimited/default size setting is
restored.
The Store protocol safety check is non-destructive and requires the two
disposable users retained by `store-user-layout-check.sh`:
+487
View File
@@ -0,0 +1,487 @@
#!/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 SMTP SIZE boundaries and low Queue-space responses."""
from __future__ import annotations
import argparse
import copy
import json
import os
import re
import socket
import subprocess
import sys
import time
from bongo.store.StoreClient import StoreClient
ADMIN = os.environ.get("BONGO_TEST_ADMIN", "/usr/bin/bongo-admin")
QUEUE_TOOL = os.environ.get(
"BONGO_TEST_QUEUE_TOOL", "/usr/bin/bongo-queuetool"
)
SYSTEMCTL = os.environ.get("BONGO_TEST_SYSTEMCTL", "/usr/bin/systemctl")
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"))
USER = os.environ.get("BONGO_TEST_USER", "test1")
DOMAIN = os.environ.get("BONGO_TEST_DOMAIN", "bongo.test")
PASSWORD = os.environ.get("BONGO_TEST_PASSWORD", "")
LIMIT = int(os.environ.get("BONGO_TEST_MESSAGE_LIMIT", "1024"))
TIMEOUT = float(os.environ.get("BONGO_TEST_TIMEOUT", "30"))
STARTUP_GRACE = float(os.environ.get("BONGO_TEST_STARTUP_GRACE", "2"))
ALLOW_LIVE = os.environ.get("BONGO_ALLOW_LIVE_SMTP_TEST") == "1"
TOKEN = f"smtp11-{os.getpid()}-{int(time.time())}"
TOKEN_PREFIX = b"smtp11-"
RESPONSE = re.compile(rb"^([0-9]{3})([- ])(.*)\r\n$")
QUEUE_ID = re.compile(r"^[0-9A-Fa-f]{3}-[0-9A-Fa-f]+$")
class SMTP11Error(RuntimeError):
"""Raised when SMTP size or free-space handling is incorrect."""
def run(
arguments: list[str],
*,
input_text: str | None = None,
binary: bool = False,
check: bool = True,
) -> subprocess.CompletedProcess:
completed = subprocess.run(
arguments,
input=input_text,
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 SMTP11Error(
f"{' '.join(arguments)} failed: "
f"{stderr.strip() or stdout.strip()}"
)
return completed
def admin(*arguments: str, input_text: str | None = None) -> str:
return run(
["sudo", "-n", ADMIN, *arguments], input_text=input_text
).stdout
def queue(
*arguments: str, binary: bool = False, check: bool = True
) -> subprocess.CompletedProcess:
return run(
["sudo", "-n", "-u", "bongo", QUEUE_TOOL, *arguments],
binary=binary,
check=check,
)
def read_configuration() -> dict:
try:
value = json.loads(admin("__config-read", "smtp"))
except json.JSONDecodeError as error:
raise SMTP11Error(
"bongo-admin returned invalid SMTP configuration"
) from error
if not isinstance(value, dict):
raise SMTP11Error("SMTP configuration is not an object")
return value
def replace_configuration(configuration: dict) -> None:
admin(
"__config-replace",
"smtp",
input_text=json.dumps(configuration, separators=(",", ":")),
)
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 SMTP11Error("connection closed while awaiting response")
if len(line) > 8192:
raise SMTP11Error("SMTP response line exceeded 8192 bytes")
match = RESPONSE.match(line)
if match is None:
raise SMTP11Error(f"malformed SMTP response: {line!r}")
code = int(match.group(1))
if response_code is None:
response_code = code
elif code != response_code:
raise SMTP11Error(
f"multiline response changed status: {line!r}"
)
lines.append(match.group(3))
if match.group(2) == b" ":
return code, lines
def expect(self, expected: int, context: str) -> list[bytes]:
code, lines = self.response()
if code != expected:
raise SMTP11Error(
f"{context}: expected SMTP {expected}, got {code}: {lines!r}"
)
return lines
def command(self, command: str) -> tuple[int, list[bytes]]:
self.socket.sendall(command.encode("ascii") + b"\r\n")
return self.response()
def require(
result: tuple[int, list[bytes]], expected: int, context: str
) -> list[bytes]:
code, lines = result
if code != expected:
raise SMTP11Error(
f"{context}: expected SMTP {expected}, got {code}: {lines!r}"
)
return lines
def wait_port() -> None:
deadline = time.monotonic() + 30
consecutive = 0
while time.monotonic() < deadline:
client: SMTPConnection | None = None
try:
client = SMTPConnection()
client.expect(220, "readiness greeting")
require(client.command("QUIT"), 221, "readiness QUIT")
consecutive += 1
if consecutive == 2:
return
time.sleep(0.25)
except (OSError, SMTP11Error):
consecutive = 0
time.sleep(0.1)
finally:
if client is not None:
client.close()
raise SMTP11Error(f"SMTP did not become ready at {HOST}:{PORT}")
def restart_bongo() -> None:
run(["sudo", "-n", SYSTEMCTL, "restart", "bongo.service"])
time.sleep(STARTUP_GRACE)
wait_port()
active = run(
["sudo", "-n", SYSTEMCTL, "is-active", "bongo.service"]
).stdout.strip()
if active != "active":
raise SMTP11Error("bongo.service is not active after restart")
def make_message(length: int, token: str) -> bytes:
prefix = (
f"From: SMTP-11 <sender@example.test>\r\n"
f"To: {USER}@{DOMAIN}\r\n"
f"Subject: SMTP-11 {token}\r\n"
f"Message-ID: <{token}@{DOMAIN}>\r\n"
f"X-Bongo-Test: {token}\r\n"
"\r\n"
"size-boundary="
).encode("ascii")
if length < len(prefix) + 2:
raise SMTP11Error(
f"message limit {length} is too small for the test fixture"
)
message = prefix + b"x" * (length - len(prefix) - 2) + b"\r\n"
if len(message) != length:
raise SMTP11Error("internal message-size construction error")
return message
def ehlo(client: SMTPConnection) -> list[bytes]:
client.expect(220, "greeting")
return require(
client.command("EHLO smtp11-client.example"), 250, "EHLO"
)
def start_local_transaction(
client: SMTPConnection, *, size: int | None
) -> None:
suffix = "" if size is None else f" SIZE={size}"
require(
client.command(f"MAIL FROM:<sender@example.test>{suffix}"),
250,
"MAIL FROM",
)
require(
client.command(f"RCPT TO:<{USER}@{DOMAIN}>"),
250,
"RCPT TO",
)
def send_data(client: SMTPConnection, message: bytes) -> tuple[int, list[bytes]]:
require(client.command("DATA"), 354, "DATA")
client.socket.sendall(message + b".\r\n")
return client.response()
def queue_token_ids() -> list[str]:
identifiers: list[str] = []
for line in queue("list").stdout.splitlines():
fields = line.split()
if not fields or QUEUE_ID.fullmatch(fields[0]) is None:
continue
message = queue("message", fields[0], binary=True, check=False)
if message.returncode == 0 and TOKEN_PREFIX in message.stdout:
identifiers.append(fields[0])
return identifiers
def open_store() -> StoreClient:
return StoreClient(
USER,
USER,
authPassword=PASSWORD,
host=HOST,
port=STORE_PORT,
)
def store_token_documents(store: StoreClient) -> list[str]:
matches: list[str] = []
# LIST is a streamed Store response. Consume it completely before issuing
# READ commands on the same connection or payload bytes can be mistaken
# for subsequent response lines.
identifiers = [entry.uid for entry in store.List("/mail/INBOX")]
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
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_exact_delivery(store: StoreClient) -> str:
deadline = time.monotonic() + TIMEOUT
while time.monotonic() < deadline:
documents = store_token_documents(store)
if len(documents) > 1:
raise SMTP11Error(
f"exact-limit message delivered more than once: {documents!r}"
)
if documents:
return documents[0]
time.sleep(0.25)
raise SMTP11Error("exact-limit message did not reach the Store")
def normal_checks() -> int:
if not ALLOW_LIVE:
raise SMTP11Error(
"set BONGO_ALLOW_LIVE_SMTP_TEST=1 for the live configuration test"
)
if not PASSWORD:
raise SMTP11Error("BONGO_TEST_PASSWORD is required")
if LIMIT < 256:
raise SMTP11Error("BONGO_TEST_MESSAGE_LIMIT must be at least 256")
original = read_configuration()
configured = copy.deepcopy(original)
configured["message_size_limit"] = LIMIT
restored = False
store: StoreClient | None = None
try:
replace_configuration(configured)
restart_bongo()
store = open_store()
cleanup(store)
client = SMTPConnection()
try:
capabilities = ehlo(client)
if f"SIZE {LIMIT}".encode("ascii") not in capabilities:
raise SMTP11Error(
f"EHLO did not advertise SIZE {LIMIT}: {capabilities!r}"
)
lines = require(
client.command(
f"MAIL FROM:<sender@example.test> SIZE={LIMIT + 1}"
),
552,
"declared over-limit MAIL FROM",
)
if b"size" not in b" ".join(lines).lower():
raise SMTP11Error(
f"SMTP 552 did not explain the size failure: {lines!r}"
)
require(client.command("NOOP"), 250, "NOOP after MAIL rejection")
start_local_transaction(client, size=LIMIT)
require(
send_data(client, make_message(LIMIT, TOKEN)),
250,
"exact-limit DATA",
)
require(client.command("NOOP"), 250, "NOOP after exact DATA")
start_local_transaction(client, size=None)
require(
send_data(
client,
make_message(LIMIT + 1, f"{TOKEN}-over"),
),
552,
"undeclared over-limit DATA",
)
require(client.command("NOOP"), 250, "NOOP after DATA rejection")
require(client.command("QUIT"), 221, "QUIT")
finally:
client.close()
document = wait_for_exact_delivery(store)
store.Delete(document)
cleanup(store)
if store_token_documents(store) or queue_token_ids():
raise SMTP11Error("test content remains after cleanup")
replace_configuration(original)
restart_bongo()
restored = True
finally:
if store is not None:
try:
if not restored:
cleanup(store)
except Exception as error:
print(
f"SMTP-11 cleanup warning: {error}", file=sys.stderr
)
finally:
try:
store.Quit()
except Exception:
pass
if not restored:
try:
replace_configuration(original)
restart_bongo()
except Exception as error:
print(
f"SMTP-11 restore warning: {error}", file=sys.stderr
)
print(
"SMTP-11 SIZE PASS "
f"limit={LIMIT} advertised=yes declared-over=552 "
"exact-data=250 absent-size-over=552 session-recovery=yes "
"queue-clean=yes store-clean=yes config-restored=yes"
)
return 0
def disk_checks() -> int:
client = SMTPConnection()
try:
ehlo(client)
lines = require(
client.command("MAIL FROM:<sender@example.test> SIZE=1"),
452,
"declared-size full-spool MAIL FROM",
)
if b"storage" not in b" ".join(lines).lower():
raise SMTP11Error(
f"SMTP 452 did not explain the storage failure: {lines!r}"
)
require(
client.command("NOOP"), 250, "NOOP after QDSPC rejection"
)
require(client.command("QUIT"), 221, "QUIT")
finally:
client.close()
client = SMTPConnection()
try:
ehlo(client)
lines = require(
client.command("MAIL FROM:<sender@example.test>"),
452,
"undeclared-size full-spool MAIL FROM",
)
if b"storage" not in b" ".join(lines).lower():
raise SMTP11Error(
f"SMTP 452 did not explain the storage failure: {lines!r}"
)
finally:
client.close()
print(
"SMTP-11 DISK PASS declared-size=452 no-size=452 "
"enhanced-status=4.3.1"
)
return 0
def main() -> int:
parser = argparse.ArgumentParser()
parser.add_argument(
"--disk-only",
action="store_true",
help="require a pre-existing full-spool fixture and test SMTP 452",
)
arguments = parser.parse_args()
return disk_checks() if arguments.disk_only else normal_checks()
if __name__ == "__main__":
try:
raise SystemExit(main())
except (OSError, SMTP11Error, ValueError) as error:
print(f"SMTP-11 FAIL: {type(error).__name__}: {error}")
raise SystemExit(1)
+105
View File
@@ -0,0 +1,105 @@
#!/bin/sh
#
# Exercise SMTP SIZE boundaries, then overlay the disposable Queue spool with
# a full tmpfs and verify both predeclared and undeclared-size 452 responses.
set -eu
script_dir=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
python_check=${BONGO_SMTP_SIZE_CHECK:-"$script_dir/smtp-size-check.py"}
queue_tool=${BONGO_QUEUE_TOOL:-/usr/bin/bongo-queuetool}
service=${BONGO_SERVICE:-bongo.service}
spool=${BONGO_QUEUE_SPOOL:-/var/lib/bongo/spool}
run_as=${BONGO_TEST_RUN_AS:-bongo}
as_root=${BONGO_TEST_AS_ROOT:-sudo -n}
if [ "$(id -un)" = "$run_as" ]; then
as_bongo=
else
as_bongo="sudo -n -u $run_as"
fi
work=$(mktemp -d "${TMPDIR:-/tmp}/bongo-smtp11.XXXXXX")
chmod 0755 "$work"
tmpfs="$work/tmpfs"
mkdir "$tmpfs"
chmod 0777 "$tmpfs"
tmpfs_mounted=0
spool_mounted=0
fill_open=0
restore()
{
status=$?
if [ "$fill_open" -eq 1 ]; then
exec 9>&-
fill_open=0
fi
$as_root /usr/bin/systemctl stop "$service" >/dev/null 2>&1 || true
if [ "$spool_mounted" -eq 1 ]; then
$as_root /usr/bin/umount "$spool" >/dev/null 2>&1 || true
fi
if [ "$tmpfs_mounted" -eq 1 ]; then
$as_root /usr/bin/umount "$tmpfs" >/dev/null 2>&1 || true
fi
$as_root /usr/bin/systemctl start "$service" >/dev/null 2>&1 || true
rm -rf "$work"
exit "$status"
}
trap restore EXIT HUP INT TERM
wait_queue()
{
attempts=30
while [ "$attempts" -gt 0 ]; do
if $as_bongo "$queue_tool" space >/dev/null 2>&1; then
return 0
fi
attempts=$((attempts - 1))
sleep 1
done
echo "SMTP-11: Queue did not become ready after service restart" >&2
return 1
}
normal_output=$("$python_check")
printf '%s\n' "$normal_output"
if [ -n "$($as_bongo "$queue_tool" list)" ]; then
echo "SMTP-11: refusing to hide a non-empty live Queue spool" >&2
exit 1
fi
$as_root /usr/bin/systemctl stop "$service"
$as_root /usr/bin/mount -t tmpfs \
-o size=8m,mode=0777 bongo-smtp11 "$tmpfs"
tmpfs_mounted=1
$as_root /usr/bin/mount --bind "$tmpfs" "$spool"
spool_mounted=1
$as_root /usr/bin/systemctl start "$service"
wait_queue
set -- $($as_bongo "$queue_tool" space details)
reported_before=$4
block=$5
fill_blocks=$((reported_before / block + 1))
exec 9>"$tmpfs/.smtp11-fill"
fill_open=1
rm "$tmpfs/.smtp11-fill"
/usr/bin/dd if=/dev/zero bs="$block" count="$fill_blocks" status=none >&9
$as_root /usr/bin/systemctl restart "$service"
wait_queue
reported=$($as_bongo "$queue_tool" space)
if [ "$reported" -ne 0 ]; then
echo "SMTP-11: simulated full spool reports $reported usable bytes" >&2
exit 1
fi
disk_output=$("$python_check" --disk-only)
printf '%s\n' "$disk_output"
printf 'SMTP-11 PASS limit=%s exact=accepted declared-over=552 ' \
"${BONGO_TEST_MESSAGE_LIMIT:-1024}"
printf 'absent-size-over=552 full-spool=452 reported=%s queue-clean=yes\n' \
"$reported"
+1 -1
View File
@@ -138,7 +138,7 @@ inputs and outputs is absent from the ZIP.
| SMTP-27 | DANE takes precedence; MTA-STS enforce/testing/cache and MX wildcard policy through complete BIND/Unbound, Technitium/Technitium, and PowerDNS/PowerDNS Recursor DNS stacks | [PASS](test-evidence/0.7-r1.md#smtp-27) | | |
| SMTP-09 | Trusted port-26/Mailman-style return receives DKIM through relayhost hostname, port, TLS, optional authentication, failure, and recovery | [PASS](test-evidence/0.7-r1.md#smtp-09) | | |
| SMTP-10 | Open-relay tests reject unauthenticated/untrusted third-party relay | [PASS](test-evidence/0.7-r1.md#smtp-10) | | |
| SMTP-11 | `SIZE` exact limit, over-limit, absent size, and disk-space responses | | | |
| SMTP-11 | `SIZE` exact limit, over-limit, absent size, and disk-space responses | [PASS](test-evidence/0.7-r1.md#smtp-11) | | |
| SMTP-12 | `PIPELINING` ordering and error recovery | | | |
| SMTP-13 | `8BITMIME` remains byte-correct | | | |
| SMTP-14 | DSN `RET`, `ENVID`, `NOTIFY`, and `ORCPT` cases | | | |
+42
View File
@@ -2521,6 +2521,48 @@ The focused native `smtp-internal-relay` policy test also passed. The Queue
listing was empty before hand-off, no configuration changed, no credential
was used, and `bongo.service` remained active.
## SMTP-11
Result: **PASS**
The reusable `smtp-size-disk-check.sh` and `smtp-size-check.py` fixtures
temporarily set the real SMTP Store document's `message_size_limit` to 1024
bytes. The port-25 EHLO response advertised the exact limit. A declared
`SIZE=1025` was rejected at `MAIL FROM` with `552`; a complete 1024-byte DATA
message was accepted and delivered exactly once to the disposable local
Store; and a 1025-byte DATA message sent without a `SIZE` parameter was read
through its terminator and rejected with `552`. `NOOP` succeeded after each
rejection, proving transaction reset and session recovery rather than a
desynchronised connection.
The second phase required an empty Queue, stopped Bongo, bind-mounted an
8-MiB tmpfs over the Queue spool, and consumed blocks through an open,
unlinked file until `QDSPC` reported zero usable bytes. With that isolated
spool, both `MAIL FROM ... SIZE=1` and `MAIL FROM` without a size received
`452 4.3.1 Insufficient system storage`. The declared-size path remained
usable after rejection. The latter path exercised failed Queue creation.
```sh
export BONGO_ALLOW_LIVE_SMTP_TEST=1
export BONGO_TEST_PASSWORD='the-disposable-test-password'
./contrib/testing/smtp-size-disk-check.sh
```
The installed `d782d593` debug build reported on 2026-07-27:
```text
SMTP-11 SIZE PASS limit=1024 advertised=yes declared-over=552 exact-data=250 absent-size-over=552 session-recovery=yes queue-clean=yes store-clean=yes config-restored=yes
SMTP-11 DISK PASS declared-size=452 no-size=452 enhanced-status=4.3.1
SMTP-11 PASS limit=1024 exact=accepted declared-over=552 absent-size-over=552 full-spool=452 reported=0 queue-clean=yes
```
The trap closed the filler descriptor, stopped the test service, removed both
mounts, restarted Bongo, and deleted its temporary directory. A final check
found no mount over `/var/lib/bongo/spool`, an empty Queue, active
`bongo.service`, and the original unlimited `message_size_limit` value of
zero. The exact-limit Store object and the interrupted first-run fixture were
also removed.
## SMTP-20
Result: **PASS**