From 54eee9efaf4ec45ae3889a7ebaff3abe5c94b0df Mon Sep 17 00:00:00 2001 From: Mario Fetka Date: Fri, 24 Jul 2026 13:24:08 +0200 Subject: [PATCH] Add load-dependent SMTP timeouts --- contrib/testing/README.md | 5 + contrib/testing/smtp-stress-timeout-check.py | 281 +++++++++++++++++++ docs/release-testing-0.7.md | 1 + docs/smtp.md | 8 + include/connio.h | 1 + src/agents/smtp/CMakeLists.txt | 10 + src/agents/smtp/load-policy.c | 46 +++ src/agents/smtp/load-policy.h | 32 +++ src/agents/smtp/smtpd.c | 44 ++- src/agents/smtp/tests/load-policy-test.c | 41 +++ src/apps/config/config/smtp | 2 + src/apps/config/tests/test_configuration.py | 28 ++ src/libs/connio/connio.c | 13 + src/libs/connio/tests/default-timeout-test.c | 5 + src/libs/python/bongo/configuration/model.py | 10 + 15 files changed, 526 insertions(+), 1 deletion(-) create mode 100755 contrib/testing/smtp-stress-timeout-check.py create mode 100644 src/agents/smtp/load-policy.c create mode 100644 src/agents/smtp/load-policy.h create mode 100644 src/agents/smtp/tests/load-policy-test.c diff --git a/contrib/testing/README.md b/contrib/testing/README.md index 86d2cb2..5aa9876 100644 --- a/contrib/testing/README.md +++ b/contrib/testing/README.md @@ -49,6 +49,11 @@ one-off files in `/tmp`: proves that slow manual `EHLO`, `NOOP`, and `RSET` input resets the timeout after each complete command. It restores the SMTP configuration and active service before exiting. +- `smtp-stress-timeout-check.py` uses only two simultaneous connections to + trigger the configurable load threshold. It proves that the first + connection keeps the normal timeout while newly accepted inbound, + trusted-device, and submission connections receive the shorter overload + timeout, then restores the exact configuration and active service. - `sendmail-local-submission-check.py` runs as an unprivileged Unix account and verifies both `/usr/sbin/sendmail` and GNU `mail` through Bongo's restricted, rate-limited local SMTP submission path. It confirms that no diff --git a/contrib/testing/smtp-stress-timeout-check.py b/contrib/testing/smtp-stress-timeout-check.py new file mode 100755 index 0000000..625ef79 --- /dev/null +++ b/contrib/testing/smtp-stress-timeout-check.py @@ -0,0 +1,281 @@ +#!/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 + +"""Verify load-dependent SMTP timeouts with two real connections.""" + +from __future__ import annotations + +import copy +import json +import os +import select +import socket +import subprocess +import time + + +ADMIN = os.environ.get("BONGO_TEST_ADMIN", "/usr/bin/bongo-admin") +SYSTEMCTL = os.environ.get("BONGO_TEST_SYSTEMCTL", "/usr/bin/systemctl") +INBOUND_HOST = os.environ.get("BONGO_TEST_INBOUND_HOST", "127.0.0.1") +INBOUND_PORT = int(os.environ.get("BONGO_TEST_INBOUND_PORT", "25")) +INTERNAL_HOST = os.environ.get( + "BONGO_TEST_INTERNAL_HOST", "172.16.11.190") +INTERNAL_PORT = int(os.environ.get("BONGO_TEST_INTERNAL_PORT", "26")) +SUBMISSION_HOST = os.environ.get( + "BONGO_TEST_SUBMISSION_HOST", "127.0.0.1") +SUBMISSION_PORT = int(os.environ.get("BONGO_TEST_SUBMISSION_PORT", "587")) +NORMAL_TIMEOUT = int(os.environ.get("BONGO_TEST_NORMAL_TIMEOUT", "5")) +STRESS_TIMEOUT = int(os.environ.get("BONGO_TEST_STRESS_TIMEOUT", "2")) +STARTUP_GRACE = float(os.environ.get("BONGO_TEST_STARTUP_GRACE", "2")) +ALLOW_LIVE = os.environ.get("BONGO_ALLOW_LIVE_SMTP_TEST") == "1" + + +class StressTimeoutError(RuntimeError): + """Raised when SMTP does not apply its load-dependent timeout.""" + + +def run(arguments: list[str], *, input_text: str | None = None) -> str: + completed = subprocess.run( + arguments, + input=input_text, + capture_output=True, + text=True, + check=False, + ) + if completed.returncode: + raise StressTimeoutError( + f"{' '.join(arguments)} failed: " + f"{completed.stderr.strip() or completed.stdout.strip()}") + return completed.stdout + + +def admin(*arguments: str, input_text: str | None = None) -> str: + return run( + ["sudo", "-n", ADMIN, *arguments], + input_text=input_text, + ) + + +def read_configuration() -> dict: + try: + configuration = json.loads(admin("__config-read", "smtp")) + except json.JSONDecodeError as error: + raise StressTimeoutError( + "bongo-admin returned invalid SMTP configuration") from error + if not isinstance(configuration, dict): + raise StressTimeoutError("SMTP configuration is not an object") + return configuration + + +def replace_configuration(configuration: dict) -> None: + admin( + "__config-replace", + "smtp", + input_text=json.dumps(configuration, separators=(",", ":")), + ) + + +def read_greeting(connection: socket.socket, name: str) -> None: + data = bytearray() + while b"\n" not in data and len(data) <= 8192: + chunk = connection.recv(4096) + if not chunk: + raise StressTimeoutError( + f"{name} closed before the SMTP greeting") + data.extend(chunk) + if not bytes(data).startswith(b"220 "): + raise StressTimeoutError( + f"{name} returned an invalid SMTP greeting: {bytes(data)!r}") + + +def open_connection(host: str, port: int, name: str) -> socket.socket: + connection = socket.create_connection((host, port), timeout=10) + connection.settimeout(10) + try: + read_greeting(connection, name) + except BaseException: + connection.close() + raise + return connection + + +def wait_closed( + connection: socket.socket, + started: float, + expected: int, + name: str, +) -> float: + deadline = started + expected + 5 + received = bytearray() + while time.monotonic() < deadline: + connection.settimeout(max(0.1, deadline - time.monotonic())) + try: + chunk = connection.recv(4096) + except socket.timeout: + break + if not chunk: + elapsed = time.monotonic() - started + if elapsed < max(0.5, expected * 0.65): + raise StressTimeoutError( + f"{name} closed too early after {elapsed:.2f}s") + return elapsed + received.extend(chunk) + raise StressTimeoutError( + f"{name} remained open beyond {expected + 5}s; " + f"unexpected data={bytes(received)!r}") + + +def assert_still_open(connection: socket.socket, name: str) -> None: + readable, _, _ = select.select([connection], [], [], 0) + if not readable: + return + data = connection.recv(4096) + if not data: + raise StressTimeoutError( + f"{name} normal connection received the stress timeout") + raise StressTimeoutError( + f"{name} normal connection returned unexpected data: {data!r}") + + +def wait_ready(host: str, port: int, name: str) -> None: + deadline = time.monotonic() + 30 + while time.monotonic() < deadline: + connection: socket.socket | None = None + try: + connection = open_connection(host, port, name) + connection.sendall(b"QUIT\r\n") + reply = connection.recv(4096) + if not reply.startswith(b"221 "): + raise OSError("SMTP did not accept readiness QUIT") + return + except (OSError, StressTimeoutError): + time.sleep(0.1) + finally: + if connection is not None: + connection.close() + raise StressTimeoutError( + f"{name} did not become ready at {host}:{port}") + + +def restart_bongo() -> None: + run(["sudo", "-n", SYSTEMCTL, "restart", "bongo.service"]) + time.sleep(STARTUP_GRACE) + for name, host, port in ( + ("inbound", INBOUND_HOST, INBOUND_PORT), + ("internal", INTERNAL_HOST, INTERNAL_PORT), + ("submission", SUBMISSION_HOST, SUBMISSION_PORT), + ): + wait_ready(host, port, name) + if run( + ["sudo", "-n", SYSTEMCTL, "is-active", "bongo.service"] + ).strip() != "active": + raise StressTimeoutError("bongo.service is not active") + + +def check_endpoint(name: str, host: str, port: int) -> tuple[float, float]: + normal: socket.socket | None = None + stressed: socket.socket | None = None + try: + normal_started = time.monotonic() + normal = open_connection(host, port, f"{name} normal") + time.sleep(0.25) + + stress_started = time.monotonic() + stressed = open_connection(host, port, f"{name} stressed") + stress_elapsed = wait_closed( + stressed, stress_started, STRESS_TIMEOUT, f"{name} stressed") + stressed.close() + stressed = None + + assert_still_open(normal, f"{name} normal") + normal_elapsed = wait_closed( + normal, normal_started, NORMAL_TIMEOUT, f"{name} normal") + normal.close() + normal = None + + if stress_elapsed >= normal_elapsed: + raise StressTimeoutError( + f"{name} stress timeout was not shorter than normal") + return normal_elapsed, stress_elapsed + finally: + if stressed is not None: + stressed.close() + if normal is not None: + normal.close() + + +def main() -> int: + if not ALLOW_LIVE: + raise StressTimeoutError( + "set BONGO_ALLOW_LIVE_SMTP_TEST=1 for the live stress test") + if not 1 <= STRESS_TIMEOUT < NORMAL_TIMEOUT <= 15: + raise StressTimeoutError( + "require 1 <= stress timeout < normal timeout <= 15 seconds") + + original = read_configuration() + test_configuration = copy.deepcopy(original) + test_configuration.update({ + "socket_timeout": NORMAL_TIMEOUT, + "stress_socket_timeout": STRESS_TIMEOUT, + "stress_load_percent": 100, + "max_thread_load": 2, + }) + results: list[str] = [] + test_error: BaseException | None = None + restore_error: BaseException | None = None + try: + replace_configuration(test_configuration) + restart_bongo() + for name, host, port in ( + ("inbound", INBOUND_HOST, INBOUND_PORT), + ("internal", INTERNAL_HOST, INTERNAL_PORT), + ("submission", SUBMISSION_HOST, SUBMISSION_PORT), + ): + normal, stressed = check_endpoint(name, host, port) + results.append( + f"{name}=normal:{normal:.2f}s,stress:{stressed:.2f}s") + except BaseException as error: + test_error = error + finally: + try: + replace_configuration(original) + restart_bongo() + if read_configuration() != original: + raise StressTimeoutError( + "SMTP configuration was not restored exactly") + except BaseException as error: + restore_error = error + + if restore_error is not None: + if test_error is not None: + raise StressTimeoutError( + f"test failed ({test_error}) and restoration also failed " + f"({restore_error})") + raise StressTimeoutError( + f"failed to restore SMTP configuration: {restore_error}") + if test_error is not None: + raise test_error + + print( + "SMTP-33 PASS " + f"maximum-connections=2 stress-threshold=100% " + f"normal-timeout={NORMAL_TIMEOUT}s stress-timeout={STRESS_TIMEOUT}s " + f"sessions={';'.join(results)} " + "config-restored=yes service=active" + ) + return 0 + + +if __name__ == "__main__": + try: + raise SystemExit(main()) + except ( + OSError, + StressTimeoutError, + subprocess.SubprocessError, + ValueError, + ) as error: + print(f"SMTP-33 FAIL: {type(error).__name__}: {error}") + raise SystemExit(1) diff --git a/docs/release-testing-0.7.md b/docs/release-testing-0.7.md index 3ef414b..eee481a 100644 --- a/docs/release-testing-0.7.md +++ b/docs/release-testing-0.7.md @@ -132,6 +132,7 @@ inputs and outputs is absent from the ZIP. | SMTP-30 | curl, swaks, Xeams, GNU Mailutils, and paced Telnet delivery over authenticated submission and unauthenticated port 25/26 | [PASS](test-evidence/0.7-r1.md#smtp-30) | | | | SMTP-31 | Unprivileged sendmail-compatible local submission without the Bongo system credential | [PASS](test-evidence/0.7-r1.md#smtp-31) | | | | SMTP-32 | Telnet activity resets the configured per-operation timeout; abandoned port 25/26 sessions close | [PASS](test-evidence/0.7-r1.md#smtp-32) | | | +| SMTP-33 | Load threshold gives newly accepted inbound, trusted-device, and submission sessions the short stress timeout | | | | | SMTP-07 | Internet delivery prefers STARTTLS and follows opportunistic fallback | | | | | SMTP-08 | Required TLS and `REQUIRETLS` defer rather than downgrade | | | | | SMTP-27 | DANE takes precedence; MTA-STS enforce/testing/cache and MX wildcard policy | | | | diff --git a/docs/smtp.md b/docs/smtp.md index 21156ee..493c9f2 100644 --- a/docs/smtp.md +++ b/docs/smtp.md @@ -41,6 +41,14 @@ worker indefinitely. The default is 300 seconds, matching Postfix's normal `smtpd_timeout`; values from 1 through 3600 seconds are accepted. Bongo rejects zero rather than treating it as an unlimited connection. +When the projected number of active SMTP connection threads reaches +`stress_load_percent` of `max_thread_load`, a newly accepted connection uses +`stress_socket_timeout` instead. The defaults are 80 percent and 10 seconds, +following Postfix's normal-versus-overload timeout model. Existing sessions +retain the timeout selected when they were accepted, while new sessions +return to the normal value automatically as load falls. The shorter policy +also covers the PROXY header and TLS negotiation performed after accept. + ## Trusted downstream proxy metadata Bongo can preserve the original SMTP client's connection address when it diff --git a/include/connio.h b/include/connio.h index 2f9e6e8..51ea344 100644 --- a/include/connio.h +++ b/include/connio.h @@ -251,6 +251,7 @@ Connection *ConnAddressPoolConnect(AddressPool *pool, unsigned long timeOut); BOOL ConnStartup(unsigned long TimeOut); BOOL ConnSetDefaultTimeout(unsigned long TimeOut); +BOOL ConnSetTimeout(Connection *Conn, unsigned long TimeOut); void ConnShutdown(void); void ConnSSLContextFree(bongo_ssl_context *Context); diff --git a/src/agents/smtp/CMakeLists.txt b/src/agents/smtp/CMakeLists.txt index 279016f..de81836 100644 --- a/src/agents/smtp/CMakeLists.txt +++ b/src/agents/smtp/CMakeLists.txt @@ -5,6 +5,7 @@ add_executable(bongosmtp internal-relay.c capabilities.c auth-results.c + load-policy.c protocol.c proxy.c ) @@ -76,6 +77,15 @@ if(BUILD_TESTING) ) add_test(NAME smtp-protocol COMMAND smtp-protocol-test) + add_executable(smtp-load-policy-test + tests/load-policy-test.c + load-policy.c + ) + target_include_directories(smtp-load-policy-test PRIVATE + ${CMAKE_CURRENT_SOURCE_DIR} + ) + add_test(NAME smtp-load-policy COMMAND smtp-load-policy-test) + add_executable(smtp-internal-relay-test tests/internal-relay-test.c internal-relay.c diff --git a/src/agents/smtp/load-policy.c b/src/agents/smtp/load-policy.c new file mode 100644 index 0000000..0a1deff --- /dev/null +++ b/src/agents/smtp/load-policy.c @@ -0,0 +1,46 @@ +/**************************************************************************** + * + * Copyright (c) 2001 Novell, Inc. All Rights Reserved. + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of version 2 of the GNU General Public License + * as published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, contact Novell, Inc. + * + * To contact Novell about this file by physical or electronic mail, + * you may find current contact information at www.novell.com. + * + ****************************************************************************/ + +#include "load-policy.h" + +#include + +unsigned long +SMTPConnectionTimeoutForLoad( + unsigned int activeConnections, + unsigned int maximumConnections, + unsigned int stressLoadPercent, + unsigned long normalTimeout, + unsigned long stressTimeout) +{ + uint64_t projected; + uint64_t threshold; + + if (maximumConnections == 0U || stressLoadPercent == 0U || + stressLoadPercent > 100U) { + return normalTimeout; + } + + projected = (uint64_t)activeConnections + 1U; + threshold = ((uint64_t)maximumConnections * stressLoadPercent + 99U) / + 100U; + return projected >= threshold ? stressTimeout : normalTimeout; +} diff --git a/src/agents/smtp/load-policy.h b/src/agents/smtp/load-policy.h new file mode 100644 index 0000000..0867f75 --- /dev/null +++ b/src/agents/smtp/load-policy.h @@ -0,0 +1,32 @@ +/**************************************************************************** + * + * Copyright (c) 2001 Novell, Inc. All Rights Reserved. + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of version 2 of the GNU General Public License + * as published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, contact Novell, Inc. + * + * To contact Novell about this file by physical or electronic mail, + * you may find current contact information at www.novell.com. + * + ****************************************************************************/ + +#ifndef BONGO_SMTP_LOAD_POLICY_H +#define BONGO_SMTP_LOAD_POLICY_H + +unsigned long SMTPConnectionTimeoutForLoad( + unsigned int activeConnections, + unsigned int maximumConnections, + unsigned int stressLoadPercent, + unsigned long normalTimeout, + unsigned long stressTimeout); + +#endif diff --git a/src/agents/smtp/smtpd.c b/src/agents/smtp/smtpd.c index a7e33f6..c82d40a 100644 --- a/src/agents/smtp/smtpd.c +++ b/src/agents/smtp/smtpd.c @@ -54,6 +54,7 @@ #include "auth-results.h" #include "external_accounts.h" #include "capabilities.h" +#include "load-policy.h" #include "protocol.h" struct { @@ -99,6 +100,8 @@ struct { int max_null_sender; int max_mx_servers; int socket_timeout; + int stress_socket_timeout; + int stress_load_percent; BOOL spf_verify; BOOL dkim_verify; BOOL dmarc_verify; @@ -107,7 +110,11 @@ struct { char *srs_domain; char *srs_secret_file; GArray *lmtp_transports; -} SMTP; +} SMTP = { + .socket_timeout = 300, + .stress_socket_timeout = 10, + .stress_load_percent = 80 +}; static BongoConfigItem LMTPTransportList = { BONGO_JSON_STRING, NULL, &SMTP.lmtp_transports @@ -166,6 +173,8 @@ static BongoConfigItem SMTPConfig[] = { BONGO_CONFIG_INT("o:max_flood_count/i", &SMTP.max_flood_count), // UNUSED ? BONGO_CONFIG_INT("o:max_null_sender/i", &SMTP.max_null_sender), BONGO_CONFIG_INT("o:socket_timeout/i", &SMTP.socket_timeout), + BONGO_CONFIG_INT("o:stress_socket_timeout/i", &SMTP.stress_socket_timeout), + BONGO_CONFIG_INT("o:stress_load_percent/i", &SMTP.stress_load_percent), BONGO_CONFIG_INT("o:max_mx_servers/i", &SMTP.max_mx_servers), { BONGO_JSON_BOOL, "o:relay_local_mail/b", &SMTP.relay_local }, { BONGO_JSON_BOOL, "o:require_auth/b", &SMTP.require_auth }, @@ -4948,6 +4957,21 @@ SMTPListenerReady(Connection *listener) return (descriptor.revents & POLLIN) ? 1 : 0; } +static void +SMTPApplyConnectionTimeout(Connection *connection) +{ + unsigned long timeout = SMTPConnectionTimeoutForLoad( + (unsigned int)XplSafeRead(SMTPConnThreads), + (unsigned int)SMTP.max_thread_load, + (unsigned int)SMTP.stress_load_percent, + (unsigned long)SMTP.socket_timeout, + (unsigned long)SMTP.stress_socket_timeout); + + if (!ConnSetTimeout(connection, timeout)) { + Log(LOG_ERROR, "Could not apply SMTP connection timeout"); + } +} + static int ServerSocketInit (void) { @@ -5063,6 +5087,7 @@ SMTPSubmissionServer(void *ignored) if (!SMTP_IS_EXITING() && errno == EINTR) continue; break; } + SMTPApplyConnectionTimeout(conn); if (!SMTPApplyProxyProtocol(conn, "SMTP submission")) { ConnClose(conn); ConnFree(conn); @@ -5150,6 +5175,7 @@ SMTPInternalServer(void *ignored) if (!SMTP_IS_EXITING() && errno == EINTR) continue; break; } + SMTPApplyConnectionTimeout(conn); if (!SMTPApplyProxyProtocol(conn, "internal SMTP")) { ConnClose(conn); ConnFree(conn); @@ -5246,6 +5272,7 @@ SMTPServer (void *ignored) break; } if (ConnAccept(SMTPServerConnection, &conn) != -1) { + SMTPApplyConnectionTimeout(conn); if (!SMTPApplyProxyProtocol(conn, "SMTP")) { ConnClose(conn); ConnFree(conn); @@ -5427,6 +5454,7 @@ SMTPSSLServer (void *ignored) break; } if (ConnAccept(SMTPServerConnectionSSL, &conn) != -1) { + SMTPApplyConnectionTimeout(conn); if (!SMTPApplyProxyProtocol(conn, "SMTPS")) { ConnClose(conn); ConnFree(conn); @@ -5634,6 +5662,20 @@ ValidateListenerConfiguration(void) Log(LOG_ERROR, "SMTP socket timeout must be between 1 and 3600 seconds"); return FALSE; } + if (SMTP.stress_socket_timeout < 1 || + SMTP.stress_socket_timeout > SMTP.socket_timeout) { + Log(LOG_ERROR, + "SMTP stress socket timeout must be between 1 and the normal timeout"); + return FALSE; + } + if (SMTP.stress_load_percent < 1 || SMTP.stress_load_percent > 100) { + Log(LOG_ERROR, "SMTP stress load percentage must be between 1 and 100"); + return FALSE; + } + if (SMTP.max_thread_load < 1) { + Log(LOG_ERROR, "SMTP maximum thread load must be greater than zero"); + return FALSE; + } return TRUE; } diff --git a/src/agents/smtp/tests/load-policy-test.c b/src/agents/smtp/tests/load-policy-test.c new file mode 100644 index 0000000..7c62ba9 --- /dev/null +++ b/src/agents/smtp/tests/load-policy-test.c @@ -0,0 +1,41 @@ +/**************************************************************************** + * + * Copyright (c) 2001 Novell, Inc. All Rights Reserved. + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of version 2 of the GNU General Public License + * as published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, contact Novell, Inc. + * + * To contact Novell about this file by physical or electronic mail, + * you may find current contact information at www.novell.com. + * + ****************************************************************************/ + +#include "load-policy.h" + +#include + +int +main(void) +{ + assert(SMTPConnectionTimeoutForLoad(0, 10000, 80, 300, 10) == 300); + assert(SMTPConnectionTimeoutForLoad(7998, 10000, 80, 300, 10) == 300); + assert(SMTPConnectionTimeoutForLoad(7999, 10000, 80, 300, 10) == 10); + assert(SMTPConnectionTimeoutForLoad(9999, 10000, 80, 300, 10) == 10); + + assert(SMTPConnectionTimeoutForLoad(0, 2, 100, 5, 2) == 5); + assert(SMTPConnectionTimeoutForLoad(1, 2, 100, 5, 2) == 2); + + assert(SMTPConnectionTimeoutForLoad(100, 0, 80, 300, 10) == 300); + assert(SMTPConnectionTimeoutForLoad(100, 100, 0, 300, 10) == 300); + assert(SMTPConnectionTimeoutForLoad(100, 100, 101, 300, 10) == 300); + return 0; +} diff --git a/src/apps/config/config/smtp b/src/apps/config/config/smtp index 8a0b7c8..8004a47 100644 --- a/src/apps/config/config/smtp +++ b/src/apps/config/config/smtp @@ -47,6 +47,8 @@ "max_flood_count": 1000, "max_null_sender": 10, "socket_timeout": 300, + "stress_socket_timeout": 10, + "stress_load_percent": 80, "max_mx_servers": 20, "require_auth": false, "spf_verify": true, diff --git a/src/apps/config/tests/test_configuration.py b/src/apps/config/tests/test_configuration.py index 39b019a..b369a3e 100644 --- a/src/apps/config/tests/test_configuration.py +++ b/src/apps/config/tests/test_configuration.py @@ -131,6 +131,34 @@ class ConfigurationModelTest(unittest.TestCase): for issue in errors(validate_all( configs, defaults, check_files=False)))) + def test_smtp_stress_timeout_policy_is_bounded(self): + defaults = templates() + for key, value in ( + ("stress_socket_timeout", 0), + ("stress_socket_timeout", 3601), + ("stress_load_percent", 0), + ("stress_load_percent", 101), + ("max_thread_load", 0), + ): + with self.subTest(key=key, value=value): + configs = templates() + configs["smtp"][key] = value + problems = errors(validate_all( + configs, defaults, check_files=False)) + self.assertTrue(any( + issue.document == "smtp" and issue.path == key + for issue in problems)) + + configs = templates() + configs["smtp"]["socket_timeout"] = 5 + configs["smtp"]["stress_socket_timeout"] = 6 + problems = errors(validate_all( + configs, defaults, check_files=False)) + self.assertTrue(any( + issue.document == "smtp" and + issue.path == "stress_socket_timeout" + for issue in problems)) + def test_safe_reverse_proxy_scenario(self): defaults = templates() scenario = Scenario( diff --git a/src/libs/connio/connio.c b/src/libs/connio/connio.c index 21ca691..92e4f5b 100644 --- a/src/libs/connio/connio.c +++ b/src/libs/connio/connio.c @@ -150,6 +150,19 @@ ConnSetDefaultTimeout(unsigned long TimeOut) return TRUE; } +BOOL +ConnSetTimeout(Connection *Conn, unsigned long TimeOut) +{ + if (Conn == NULL || TimeOut == 0 || + TimeOut > (unsigned long)(INT_MAX / 1000)) { + return FALSE; + } + + Conn->receive.timeOut = TimeOut; + Conn->send.timeOut = TimeOut; + return TRUE; +} + void ConnSSLContextFree(bongo_ssl_context *context) { diff --git a/src/libs/connio/tests/default-timeout-test.c b/src/libs/connio/tests/default-timeout-test.c index fc3d56e..0bda4e8 100644 --- a/src/libs/connio/tests/default-timeout-test.c +++ b/src/libs/connio/tests/default-timeout-test.c @@ -48,6 +48,11 @@ main(void) assert(after->send.timeOut == 3); assert(before->receive.timeOut == 600); assert(before->send.timeOut == 600); + assert(!ConnSetTimeout(NULL, 2)); + assert(!ConnSetTimeout(before, 0)); + assert(ConnSetTimeout(before, 2)); + assert(before->receive.timeOut == 2); + assert(before->send.timeOut == 2); ConnShutdown(); return 0; diff --git a/src/libs/python/bongo/configuration/model.py b/src/libs/python/bongo/configuration/model.py index 4b08bb8..a3b72a4 100644 --- a/src/libs/python/bongo/configuration/model.py +++ b/src/libs/python/bongo/configuration/model.py @@ -578,7 +578,17 @@ def _validate_smtp(config: Mapping[str, Any], issues: list[Issue], check_files: _issue(issues, "smtp", "proxy_protocol_networks", "enabled PROXY protocol requires trusted proxy networks") + _positive(issues, "smtp", config, "max_thread_load", 100000) _positive(issues, "smtp", config, "socket_timeout", 3600) + _positive(issues, "smtp", config, "stress_socket_timeout", 3600) + _positive(issues, "smtp", config, "stress_load_percent", 100) + normal_timeout = config.get("socket_timeout") + stress_timeout = config.get("stress_socket_timeout") + if (isinstance(normal_timeout, int) and + isinstance(stress_timeout, int) and + stress_timeout > normal_timeout): + _issue(issues, "smtp", "stress_socket_timeout", + "stress timeout must not exceed the normal socket timeout") for key in ("xclient_trusted_destinations", "xforward_trusted_destinations", "proxy_header_trusted_destinations"):