Record internal SMTP relay test
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 | | | |
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user