diff --git a/src/mgmt/rpc/server/unit_tests/test_rpcserver.cc b/src/mgmt/rpc/server/unit_tests/test_rpcserver.cc index 0385ac43087..4c67d681e28 100644 --- a/src/mgmt/rpc/server/unit_tests/test_rpcserver.cc +++ b/src/mgmt/rpc/server/unit_tests/test_rpcserver.cc @@ -28,11 +28,16 @@ #include #include #include +#include #include #include #include #include +#include +#include +#include +#include #include "swoc/swoc_file.h" @@ -71,12 +76,75 @@ add_method_handler(const std::string &name, Func &&call) namespace { -const std::string sockPath{"tests/var/jsonrpc20_test.sock"}; -const std::string lockPath{"tests/var/jsonrpc20_test.lock"}; -constexpr int default_backlog{5}; -constexpr int default_maxRetriesOnTransientErrors{64}; -constexpr size_t default_incoming_req_max_size{32000 * 3}; -DbgCtl dbg_ctl{"rpc.test.client"}; +constexpr std::string_view rpc_test_dir_template{"ats_rpc_XXXXXX"}; +constexpr std::string_view rpc_test_socket_name{"s"}; +constexpr std::string_view rpc_test_lock_name{"l"}; +constexpr size_t max_rpc_socket_path_size{sizeof(sockaddr_un::sun_path) - 1}; + +fs::path rpcTestDir; +std::string sockPath; +std::string lockPath; +constexpr int default_backlog{5}; +constexpr int default_maxRetriesOnTransientErrors{64}; +constexpr size_t default_incoming_req_max_size{32000 * 3}; +DbgCtl dbg_ctl{"rpc.test.client"}; + +/** Prepare JSONRPC socket paths beneath @a base. + * + * This owns creation of the per-run test directory and publishes the socket + * and lock paths shared by the JSONRPC test server and clients. + * + * @param[in] base Candidate parent directory for the test directory. + * @param[out] error Reason setup failed, if a usable directory was not created. + * @return @c true if the socket and lock paths are ready for this test run. + */ +bool +try_setup_rpc_test_paths(fs::path const &base, std::string &error) +{ + auto const dir_template = (base / rpc_test_dir_template).string(); + auto const socket_path = (fs::path{dir_template} / rpc_test_socket_name).string(); + + if (socket_path.size() > max_rpc_socket_path_size) { + error = "JSONRPC test socket path is too long under " + base.string() + ": " + socket_path; + return false; + } + + std::vector mutable_template{dir_template.begin(), dir_template.end()}; + mutable_template.push_back('\0'); + + char *created_dir = mkdtemp(mutable_template.data()); + if (created_dir == nullptr) { + error = "Failed to create JSONRPC test directory under " + base.string() + ": " + std::strerror(errno); + return false; + } + + rpcTestDir = fs::path{created_dir}; + sockPath = (rpcTestDir / rpc_test_socket_name).string(); + lockPath = (rpcTestDir / rpc_test_lock_name).string(); + return true; +} + +/** Prepare JSONRPC socket paths for the test run. + * + * This prefers the environment temporary directory, then falls back to @c /tmp + * when the generated Unix-domain socket path would be too long or setup fails. + * + * @param[out] error Reason setup failed, if no candidate directory works. + * @return @c true if the socket and lock paths are ready for this test run. + */ +bool +setup_rpc_test_paths(std::string &error) +{ + if (try_setup_rpc_test_paths(fs::temp_directory_path(), error)) { + error.clear(); + return true; + } + if (try_setup_rpc_test_paths(fs::path{"/tmp"}, error)) { + error.clear(); + return true; + } + return false; +} } // end anonymous namespace @@ -88,6 +156,11 @@ struct RPCServerTestListener : Catch::EventListenerBase { void testRunStarting(Catch::TestRunInfo const & /* testRunInfo ATS_UNUSED */) override { + std::string setup_error; + bool const setup_ok = setup_rpc_test_paths(setup_error); + INFO(setup_error); + REQUIRE(setup_ok); + Layout::create(); init_diags("rpc", nullptr); RecProcessInit(); @@ -123,6 +196,11 @@ struct RPCServerTestListener : Catch::EventListenerBase { if (jsonrpcServer) { delete jsonrpcServer; // will stop the thread } + + std::error_code ec; + if (!rpcTestDir.empty()) { + fs::remove_all(rpcTestDir, ec); + } } private: diff --git a/tests/gold_tests/autest-site/conditions.test.ext b/tests/gold_tests/autest-site/conditions.test.ext index e228b19fae1..7fa77a82e42 100644 --- a/tests/gold_tests/autest-site/conditions.test.ext +++ b/tests/gold_tests/autest-site/conditions.test.ext @@ -20,6 +20,107 @@ import os import subprocess import json import re +import tempfile +import time + +from ports import get_port_number + +OPENSSL_TLS_FLAGS = { + "1.0": "-tls1", + "1.1": "-tls1_1", + "1.2": "-tls1_2", + "1.3": "-tls1_3", +} + + +def _terminate_process(process): + if process.poll() is not None: + return + process.terminate() + try: + process.wait(timeout=2) + except subprocess.TimeoutExpired: + process.kill() + process.wait(timeout=2) + + +def _probe_openssl_server(tls_version, client_probe): + """Run a local OpenSSL server for TLS capability probes. + + This owns the temporary certificate, port allocation, and server process + lifecycle so AuTest conditions can perform a real handshake with the client + being checked. Local setup failures return ``False`` so callers can skip + dependent tests instead of failing the harness. + + :param tls_version: TLS version string to look up in + ``OPENSSL_TLS_FLAGS``. + :param client_probe: Callable that receives the server port and TLS flag + and returns whether the client completed the expected handshake. + :returns: ``True`` if the client probe succeeds against the local server, + otherwise ``False``. + """ + tls_flag = OPENSSL_TLS_FLAGS.get(tls_version) + if tls_flag is None: + return False + + try: + with tempfile.TemporaryDirectory() as tmpdir: + cert_path = os.path.join(tmpdir, "cert.pem") + key_path = os.path.join(tmpdir, "key.pem") + result = subprocess.run( + [ + "openssl", + "req", + "-x509", + "-newkey", + "rsa:2048", + "-nodes", + "-sha256", + "-keyout", + key_path, + "-out", + cert_path, + "-subj", + "/CN=localhost", + "-days", + "1", + ], + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + timeout=10, + ) + if result.returncode != 0: + return False + + port = get_port_number() + server = subprocess.Popen( + [ + "openssl", + "s_server", + "-quiet", + "-accept", + f"127.0.0.1:{port}", + "-cert", + cert_path, + "-key", + key_path, + tls_flag, + "-cipher", + "DEFAULT@SECLEVEL=0", + "-www", + ], + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ) + try: + time.sleep(0.5) + if server.poll() is not None: + return False + return client_probe(port, tls_flag) + finally: + _terminate_process(server) + except (OSError, subprocess.SubprocessError): + return False def HasOpenSSLVersion(self, version): @@ -67,38 +168,39 @@ def HasLegacyTLSSupport(self): always disable both legacy versions together. If TLSv1.0 is unavailable, TLSv1.1 will be too. - The check connects to localhost on a closed port to avoid any external - network dependency. A "connection refused" error means the TLS protocol - was available but nothing was listening; "no protocols available" means - the crypto-policy blocked TLSv1.0 entirely. + The check starts a local OpenSSL server and uses an OpenSSL client to + complete a real TLSv1.0 handshake. This avoids external network dependency + while catching environments that accept legacy TLS command-line flags but + reject the protocol during an actual handshake. """ def check_tls1_support(): - try: - # Connect to localhost on a port nothing is listening on. - # This avoids external network dependency while still detecting - # whether the crypto-policy allows TLSv1.0. - result = subprocess.run( - ['openssl', 's_client', '-tls1', '-connect', '127.0.0.1:1'], - capture_output=True, - text=True, - timeout=5, - input='' # Don't wait for interactive input - ) - output = result.stdout + result.stderr - # "no protocols available" means TLSv1 is disabled by crypto-policy - if 'no protocols available' in output: + + def client_probe(port, tls_flag): + try: + result = subprocess.run( + [ + 'openssl', + 's_client', + tls_flag, + '-connect', + f'127.0.0.1:{port}', + '-cipher', + 'DEFAULT@SECLEVEL=0', + '-brief', + ], + capture_output=True, + text=True, + timeout=5, + input='Q\n') + output = result.stdout + result.stderr + return result.returncode == 0 and 'Protocol version: TLSv1' in output + except subprocess.TimeoutExpired: return False - # Connection refused or other errors mean TLSv1 was attempted - # (the protocol is available, just no server listening) - return True - except subprocess.TimeoutExpired: - # Timeout on localhost shouldn't happen, but if it does, - # assume TLSv1 is not available (safer than false positive) - return False - except Exception: - # If we can't determine, assume TLSv1 is not available (safer) - return False + except Exception: + return False + + return _probe_openssl_server("1.0", client_probe) return self.Condition(check_tls1_support, "System does not support legacy TLS protocols (TLSv1.0/TLSv1.1)") @@ -154,7 +256,6 @@ def HasCurlTLSVersionSupport(self, tls_version): """ def check_curl_tls_support(): - # Map semantic versions used by tests to curl flags. version_map = { "1.0": ("--tlsv1", "1.0"), "1.1": ("--tlsv1.1", "1.1"), @@ -165,43 +266,36 @@ def HasCurlTLSVersionSupport(self, tls_version): return False tls_flag, tls_max = version_map[tls_version] - try: - # Connect to localhost closed port to avoid network dependencies. - # "connection refused" means curl accepted the TLS flags and tried. - result = subprocess.run( - [ - "curl", - "-svk", - "--connect-timeout", - "2", - "--max-time", - "3", - tls_flag, - "--tls-max", - tls_max, - "https://127.0.0.1:1", - ], - capture_output=True, - text=True, - timeout=5, - ) - output = (result.stdout + result.stderr).lower() - unsupported_markers = [ - "unsupported protocol", - "no protocols available", - "option --tlsv", - "unknown option", - "is unknown", - ] - if any(marker in output for marker in unsupported_markers): + + def client_probe(port, _tls_flag): + try: + result = subprocess.run( + [ + "curl", + "-svk", + "--connect-timeout", + "2", + "--max-time", + "5", + "--ciphers", + "DEFAULT@SECLEVEL=0", + tls_flag, + "--tls-max", + tls_max, + f"https://127.0.0.1:{port}/", + ], + capture_output=True, + text=True, + timeout=7, + ) + output = (result.stdout + result.stderr).lower() + return result.returncode == 0 and f"ssl connection using tlsv{tls_version}" in output + except subprocess.TimeoutExpired: + return False + except Exception: return False - # Any attempt to connect implies curl accepted the TLS setting. - return True - except subprocess.TimeoutExpired: - return False - except Exception: - return False + return _probe_openssl_server(tls_version, client_probe) return self.Condition( check_curl_tls_support, "Curl does not support TLSv{version} in this environment".format(version=tls_version)) diff --git a/tests/gold_tests/autest-site/ports.py b/tests/gold_tests/autest-site/ports.py index fb36b4088df..dfa44172c99 100644 --- a/tests/gold_tests/autest-site/ports.py +++ b/tests/gold_tests/autest-site/ports.py @@ -236,6 +236,45 @@ def _get_port_by_bind(): return port +def _reserve_port(): + """ + Get a port from the global port queue. + + Returns: + A tuple containing the port value and whether it should be recycled + into the queue when the caller is done with it. + """ + _setup_port_queue() + if g_ports.qsize() > 0: + try: + port = _get_available_port(g_ports) + host.WriteVerbose("_reserve_port", f"Using port from port queue: {port}") + return port, True + except PortQueueSelectionError: + port = _get_port_by_bind() + host.WriteVerbose("_reserve_port", f"Queue was drained. Using port from a bound socket: {port}") + return port, False + + # Since the queue could not be populated, use a port via bind. + port = _get_port_by_bind() + host.WriteVerbose("_reserve_port", f"Queue is empty. Using port from a bound socket: {port}") + return port, False + + +def get_port_number(): + """ + Get a port number from the same allocator used by get_port(). + + This is useful for helper code that needs a temporary listening port but + does not have an AuTest object with Setup hooks for recycling it. + + Returns: + A port value. + """ + port, _ = _reserve_port() + return port + + def get_port(obj, name): ''' Get a port and set it to the specified variable on the object. @@ -247,22 +286,10 @@ def get_port(obj, name): Returns: The port value. ''' - _setup_port_queue() - port = 0 - if g_ports.qsize() > 0: - try: - port = _get_available_port(g_ports) - host.WriteVerbose("get_port", f"Using port from port queue: {port}") - # setup clean up step to recycle the port - obj.Setup.Lambda( - func_cleanup=lambda: g_ports.put(port), description=f"recycling port: {port}, queue size: {g_ports.qsize()}") - except PortQueueSelectionError: - port = _get_port_by_bind() - host.WriteVerbose("get_port", f"Queue was drained. Using port from a bound socket: {port}") - else: - # Since the queue could not be populated, use a port via bind. - port = _get_port_by_bind() - host.WriteVerbose("get_port", f"Queue is empty. Using port from a bound socket: {port}") + port, recycle_port = _reserve_port() + if recycle_port: + obj.Setup.Lambda( + func_cleanup=lambda: g_ports.put(port), description=f"recycling port: {port}, queue size: {g_ports.qsize()}") # Assign to the named variable. obj.Variables[name] = port diff --git a/tests/gold_tests/tls/tls_check_cert_select_plugin.test.py b/tests/gold_tests/tls/tls_check_cert_select_plugin.test.py index 6cc069ea03e..92ce0048a15 100644 --- a/tests/gold_tests/tls/tls_check_cert_select_plugin.test.py +++ b/tests/gold_tests/tls/tls_check_cert_select_plugin.test.py @@ -170,7 +170,7 @@ tr.ReturnCode = 60 tr.StillRunningAfter = server tr.StillRunningAfter = ts -tr.Processes.Default.Streams.All = Testers.ContainsExpression("unknown CA", "Failed handshake") -tr.Processes.Default.Streams.All += Testers.ExcludesExpression("CN=bar.com", "Cert should contain bar.com") +tr.Processes.Default.Streams.All = Testers.ContainsExpression(r"curl: \(60\) SSL certificate", "Failed certificate verification") +tr.Processes.Default.Streams.All += Testers.ContainsExpression("CN=bar.com", "Cert should contain bar.com") tr.Processes.Default.Streams.All += Testers.ExcludesExpression("CN=foo.com", "Cert should not contain foo.com") tr.Processes.Default.Streams.All += Testers.ExcludesExpression("404", "Should make an exchange")