Record internal SMTP relay test

This commit is contained in:
Mario Fetka
2026-07-24 07:12:47 +02:00
parent 4217bd86b9
commit 286b12decb
4 changed files with 99 additions and 12 deletions
+20
View File
@@ -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
+37 -11
View File
@@ -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
+1 -1
View File
@@ -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 | | | |
+41
View File
@@ -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.