diff --git a/contrib/testing/README.md b/contrib/testing/README.md index caa5e19..1015c98 100644 --- a/contrib/testing/README.md +++ b/contrib/testing/README.md @@ -60,7 +60,9 @@ one-off files in `/tmp`: envelope plus an unsigned, non-SRS internal LMTP message while an unreachable global relayhost is enabled. This proves that internal LMTP routing takes precedence, after which the test restores the exact SMTP - configuration and active service. + configuration and active service. The fixture itself is unprivileged; the + script uses sudo only for the already-authorized `bongo-admin` and + `systemctl` operations. - `smtp-client-interoperability-check.py` repeats real delivery with curl, swaks, Xeams Email Sender, GNU Mailutils, and paced Telnet input. It covers authenticated STARTTLS submission plus unauthenticated local delivery on diff --git a/contrib/testing/smtp-lmtp-delivery-check.py b/contrib/testing/smtp-lmtp-delivery-check.py index e4ff357..dfb3b05 100755 --- a/contrib/testing/smtp-lmtp-delivery-check.py +++ b/contrib/testing/smtp-lmtp-delivery-check.py @@ -52,6 +52,12 @@ def load_helpers(): SMTP07 = load_helpers() +def privileged(arguments: list[str]) -> list[str]: + if os.geteuid() == 0: + return arguments + return ["/usr/bin/sudo", "-n", *arguments] + + @dataclass class LMTPCapture: lock: threading.Lock = field(default_factory=threading.Lock) @@ -167,7 +173,9 @@ class ThreadingLMTPServer( def read_configuration(name: str) -> dict: - completed = SMTP07.run([SMTP07.ADMIN, "__config-read", name]) + completed = SMTP07.run( + privileged([SMTP07.ADMIN, "__config-read", name]) + ) try: value = json.loads(completed.stdout) except json.JSONDecodeError as error: @@ -181,11 +189,26 @@ def read_configuration(name: str) -> dict: def replace_configuration(name: str, value: dict) -> None: SMTP07.run( - [SMTP07.ADMIN, "__config-replace", name], + privileged([SMTP07.ADMIN, "__config-replace", name]), input_text=json.dumps(value, separators=(",", ":")), ) +def service_active() -> bool: + return SMTP07.run( + privileged( + [SMTP07.SYSTEMCTL, "is-active", "--quiet", "bongo.service"] + ), + check=False, + ).returncode == 0 + + +def restart_bongo() -> None: + SMTP07.run( + privileged([SMTP07.SYSTEMCTL, "restart", "bongo.service"]) + ) + + def submit(host: str, port: int) -> None: message = ( f"From: Mailman return <{SENDER}>\r\n" @@ -254,9 +277,7 @@ def validate(delivery: tuple[bytes, bytes, bytes]) -> None: def perform_test() -> None: - if os.geteuid() != 0: - raise SMTP20Error("the live LMTP test must run as root") - originally_active = SMTP07.service_active() + originally_active = service_active() if not originally_active: raise SMTP20Error("bongo.service must be active before this test") @@ -305,7 +326,7 @@ def perform_test() -> None: try: replace_configuration("smtp", test_smtp) configuration_changed = True - SMTP07.run([SMTP07.SYSTEMCTL, "restart", "bongo.service"]) + restart_bongo() SMTP07.INTERNAL_HOST = internal_host SMTP07.INTERNAL_PORT = internal_port SMTP07.wait_for_bongo() @@ -331,7 +352,7 @@ def perform_test() -> None: restoration_errors.append(f"LMTP fixture shutdown failed: {error}") if originally_active: try: - SMTP07.run([SMTP07.SYSTEMCTL, "restart", "bongo.service"]) + restart_bongo() SMTP07.INTERNAL_HOST = internal_host SMTP07.INTERNAL_PORT = internal_port SMTP07.wait_for_bongo() diff --git a/docs/release-testing-0.7.md b/docs/release-testing-0.7.md index 1acb794..4d59a22 100644 --- a/docs/release-testing-0.7.md +++ b/docs/release-testing-0.7.md @@ -148,7 +148,7 @@ inputs and outputs is absent from the ZIP. | SMTP-26 | IDNA2008 domains use one canonical A-label in config, aliases, DNS lookup, SMTP and Web compose | | | | | SMTP-18 | Recipient validation, multiple recipients, partial refusal, aliases | | | | | SMTP-19 | Domain/address aliases, forwarding loops, and SRS forwarding | | | | -| SMTP-20 | Trusted internal SMTP to disposable LMTP preserves the envelope and adds neither DKIM nor SRS | | | | +| SMTP-20 | Trusted internal SMTP to disposable LMTP preserves the envelope and adds neither DKIM nor SRS | [PASS](test-evidence/0.7-r1.md#smtp-20) | | | | SMTP-21 | LMTP per-recipient 2xx, 4xx, 5xx, retry, and duplicate prevention | | | | | SMTP-22 | XCLIENT/XFORWARD negotiation only to trusted destinations | | | | | SMTP-23 | Fallback client-address header only for configured trusted peers | | | | diff --git a/docs/test-evidence/0.7-r1.md b/docs/test-evidence/0.7-r1.md index 0fcdde3..3672a97 100644 --- a/docs/test-evidence/0.7-r1.md +++ b/docs/test-evidence/0.7-r1.md @@ -2452,22 +2452,46 @@ This models mail returned from Mailman over the trusted internal SMTP listener: internal ingress is not signed merely on receipt, but the message is signed before it leaves Bongo for an external relay or recipient. -The recovery phase deliberately installs an incorrect relay password, -verifies that the message remains in Queue 7 and is not accepted by the -relay, repairs the secret, explicitly retries the retained entry, and -requires successful signed delivery. Commit `8f0c1518` makes this -administrative retry reliable by reserving the queue entry before `QRUN` -acknowledges it; a concurrent attempt is now reported as busy instead of -silently losing the retry. +The two recovery phases first select a bound but non-listening loopback port +and then install an incorrect relay password. In both cases the test verifies +that the message remains in Queue 7 and is not accepted by the relay. It then +repairs the endpoint or secret, retries the retained entry, and requires +successful signed delivery. Commit `8f0c1518` makes this administrative retry +reliable by reserving the queue entry before `QRUN` acknowledges it; a +concurrent attempt is now reported as busy instead of silently losing the +retry. ```text -SMTP-09 PASS hostname=localhost ports=12525/12465 starttls=no-auth implicit-tls=auth failure=retained recovery=delivered trusted-port26=dkim-signed dkim=verified queue-clean=yes config-restored=yes +SMTP-09 PASS hostname=localhost ports=12525/12465 starttls=no-auth implicit-tls=auth unreachable=retained/recovered auth-failure=retained/recovered trusted-port26=dkim-signed dkim=verified queue-clean=yes config-restored=yes ``` The test restored the exact SMTP Store document, removed its queue entries, password and CA files, and returned `bongo.service` to its original active state. +## SMTP-20 + +Result: **PASS** + +The reusable `smtp-lmtp-delivery-check.py` starts a loopback-only, +status-aware LMTP fixture on an unprivileged port. It adds one temporary +transport-domain mapping, enables an intentionally unreachable global +relayhost, and submits a Mailman-style message through trusted port 26. + +The installed Bongo client selected the internal LMTP transport before the +global relayhost, preserved the original envelope sender and recipient, and +delivered exactly one message. The captured internal copy had neither a +`DKIM-Signature` nor an SRS envelope. This proves both the internal-delivery +exception and that enabling a global relayhost cannot redirect LMTP traffic +outside Bongo. + +```text +SMTP-20 PASS ingress=trusted-internal-smtp transport=lmtp relayhost=bypassed envelope=original dkim=absent srs=absent duplicate=no queue-clean=yes config-restored=yes service-restored=active +``` + +The test removed its marked Queue data, stopped the fixture, restored the +exact SMTP Store document, and returned `bongo.service` to the active state. + ## SMTP-27 Result: **PASS**