diff --git a/contrib/testing/README.md b/contrib/testing/README.md index 0efec28..482e647 100644 --- a/contrib/testing/README.md +++ b/contrib/testing/README.md @@ -355,6 +355,26 @@ sudo ./contrib/testing/cyrus-fixture.py stop sudo ./contrib/testing/cyrus-fixture.py reset ``` +## Internal SMTP relay policy + +The live port-26 check changes rate limits and restarts Bongo several times. +It therefore requires an explicit opt-in and must only be run against a +disposable test installation: + +```sh +export BONGO_ALLOW_LIVE_SMTP_TEST=1 +./contrib/testing/smtp-internal-relay-check.py +``` + +The default test topology binds the allowed client to `172.16.11.190` and the +denied client to `127.0.0.1`, both connecting to +`172.16.11.190:26`. Override `BONGO_TEST_INTERNAL_HOST`, +`BONGO_TEST_ALLOWED_SOURCE`, or `BONGO_TEST_DENIED_SOURCE` when the fixture +uses different addresses. The script verifies exact protocol responses, +temporarily tests one connection, one recipient, 64 bytes, and one message +per rate window, and restores the original SMTP document even after failure. +Its DATA transactions are deliberately rejected before Queue commit. + ## Authenticated external SMTP provider Xeams DevNullSMTP is useful as a deliberately simple relay capture, but it diff --git a/contrib/testing/smtp-internal-relay-check.py b/contrib/testing/smtp-internal-relay-check.py index de570b4..a5928bd 100755 --- a/contrib/testing/smtp-internal-relay-check.py +++ b/contrib/testing/smtp-internal-relay-check.py @@ -13,6 +13,7 @@ import os import re import socket import subprocess +import sys import time @@ -80,6 +81,7 @@ def replace_configuration(configuration: dict) -> None: def wait_port() -> None: deadline = time.monotonic() + 30 + consecutive = 0 while time.monotonic() < deadline: sock = None try: @@ -92,9 +94,12 @@ def wait_port() -> None: sock.sendall(b"QUIT\r\n") if not sock.recv(8192).startswith(b"221 "): raise OSError("internal SMTP did not complete readiness probe") - time.sleep(0.1) - return + consecutive += 1 + if consecutive == 2: + return + time.sleep(0.25) except OSError: + consecutive = 0 time.sleep(0.1) finally: if sock is not None: @@ -131,10 +136,22 @@ def configuration_with_limits( class SMTPConnection: def __init__(self, source: str = ALLOWED_SOURCE) -> None: - sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) - sock.settimeout(TIMEOUT) - sock.bind((source, 0)) - sock.connect((HOST, PORT)) + deadline = time.monotonic() + TIMEOUT + while True: + sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + sock.settimeout(TIMEOUT) + try: + sock.bind((source, 0)) + sock.connect((HOST, PORT)) + break + except ConnectionRefusedError: + sock.close() + if time.monotonic() >= deadline: + raise + time.sleep(0.1) + except BaseException: + sock.close() + raise self.socket = sock self.reader = sock.makefile("rb") @@ -331,6 +348,14 @@ def check_message_limit(original: dict) -> None: client.close() +def phase(name: str, function, *arguments) -> None: + print(f"SMTP-04: {name}", file=sys.stderr, flush=True) + try: + function(*arguments) + except (InternalRelayCheckError, OSError, ValueError) as error: + raise InternalRelayCheckError(f"{name}: {error}") from error + + def main() -> int: if not ALLOW_LIVE: raise InternalRelayCheckError( @@ -357,11 +382,12 @@ def main() -> int: restored = False try: - check_network_policy() - check_connection_limit(original) - check_recipient_limit(original) - check_byte_limit(original) - check_message_limit(original) + phase("network policy", check_network_policy) + phase("connection limit", check_connection_limit, original) + phase("recipient limit", check_recipient_limit, original) + phase("byte limit", check_byte_limit, original) + phase("message limit", check_message_limit, original) + print("SMTP-04: restore configuration", file=sys.stderr, flush=True) replace_configuration(original) restart_bongo() restored = read_configuration() == original diff --git a/docs/release-testing-0.7.md b/docs/release-testing-0.7.md index da525a2..ee52905 100644 --- a/docs/release-testing-0.7.md +++ b/docs/release-testing-0.7.md @@ -126,7 +126,7 @@ inputs and outputs is absent from the ZIP. | SMTP-01 | Port 25 greeting, HELO, EHLO, NOOP, RSET, HELP, VRFY policy, QUIT | [PASS](test-evidence/0.7-r1.md#smtp-01) | | | | SMTP-02 | Port 587 STARTTLS submission with shared GSASL authentication | [PASS](test-evidence/0.7-r1.md#smtp-0203) | | | | SMTP-03 | Port 465 implicit TLS submission | [PASS](test-evidence/0.7-r1.md#smtp-0203) | | | -| SMTP-04 | Trusted port 26 accepts only configured networks and applies limits | | | | +| SMTP-04 | Trusted port 26 accepts only configured networks and applies limits | [PASS](test-evidence/0.7-r1.md#smtp-04) | | | | SMTP-05 | Internal sender/domain normalisation uses peer DNS/IP mapping safely | | | | | SMTP-06 | Local authenticated delivery from test1 to test2 | | | | | SMTP-07 | Internet delivery prefers STARTTLS and follows opportunistic fallback | | | | diff --git a/docs/test-evidence/0.7-r1.md b/docs/test-evidence/0.7-r1.md index 2ac4ae8..d86cbd2 100644 --- a/docs/test-evidence/0.7-r1.md +++ b/docs/test-evidence/0.7-r1.md @@ -2058,3 +2058,44 @@ The focused native `sasl-password-mechanisms`, `smtp-capabilities`, and `smtp-protocol` tests also passed. The live check authenticates only; it sends no envelope or message data and therefore leaves Store, Queue, and external systems unchanged. + +## SMTP-04 + +Result: **PASS** + +The reusable `smtp-internal-relay-check.py` exercised the dedicated internal +SMTP listener on `172.16.11.190:26`: + +```sh +export BONGO_ALLOW_LIVE_SMTP_TEST=1 +./contrib/testing/smtp-internal-relay-check.py +``` + +A connection sourced from the configured `172.16.11.0/24` network received +the normal greeting. The same destination reached from `127.0.0.1` received +exactly `554 5.7.1 Source address not permitted\r\n` and an immediate close. +This caught and fixed a manual length of 41 for the 40-byte literal, which had +placed a NUL byte on the SMTP wire. + +The test then restarted the installed service with isolated temporary limits: +one concurrent connection, one recipient per minute, 64 bytes per minute, +and one message per minute. A second held connection and a second recipient +were rejected with their specific `451` diagnostics. An oversized DATA body +was rejected by the byte limit. For the message-count case, the first DATA +transaction intentionally used an internationalized header without +`SMTPUTF8`, causing a pre-commit `550`; the next DATA transaction then proved +that the one-message limit had been consumed and returned `451`. Thus none of +the limit probes could enter Queue or Store. + +The installed-service run reported: + +```text +SMTP-04 PASS host=172.16.11.190:26 allowed=172.16.11.190 denied=127.0.0.1 network=554-exact connection-limit=451 recipient-limit=451 byte-limit=451 message-limit=451 queue-write=no restored=yes +``` + +The test restored the original SMTP JSON byte-for-byte and left +`bongo.service` active. A source-wide literal-length audit also found and +fixed `bongo-sendmail` truncating the final LF from dot-stuffed `"..\r\n"`. +The new `c-literal-io-lengths` CTest prevents NUL inclusion, truncation, and +literal over-read regressions in Bongo's connection-write helpers. The final +GCC debug suite passed all 92 CTest tests in 7.37 seconds.