| ############################################################################ | |
| # Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. | |
| ############################################################################ | |
| """Verify slurmctld rejects non-TLS RPCs when it is not serving HTTP. | |
| Companion to test_166_1, which covers the default configuration. There, | |
| slurmctld's listening socket is handed to the HTTP switch and a non-TLS RPC is | |
| refused on the way through it. With CommunicationParameters=disable_http the | |
| switch is out of the picture and the socket is a plain RPC listener, so the | |
| refusal has to come from the conmgr RPC path instead. | |
| That is the configuration in which the conmgr client-listener reject is what | |
| stands between a plaintext client and slurmctld, so it gates on the release | |
| carrying that reject rather than on the older HTTP-switch one. | |
| """ | |
| import os | |
| import pytest | |
| import requests | |
| import atf | |
| REASON = "Issue 51066: conmgr RPC reject added in 26.05.4" | |
| # slurm_errno.h. What slurmd answers an unprivileged from_slurmctld RPC. | |
| ESLURM_USER_ID_MISSING = 2010 | |
| @pytest.fixture(scope="module", autouse=True) | |
| def setup(): | |
| atf.require_tls() | |
| atf.require_tool("gcc") | |
| atf.require_config_parameter_includes("CommunicationParameters", "disable_http") | |
| atf.require_slurm_running() | |
| @pytest.fixture(scope="module") | |
| def non_tls_conf(module_setup): | |
| """Path to a slurm.conf that includes the real config but disables TLS. | |
| Absolute because the tests consuming it run in their own directory. | |
| """ | |
| real_conf = f"{atf.properties['slurm-config-dir']}/slurm.conf" | |
| path = "non_tls_slurm.conf" | |
| atf.run_command( | |
| f"printf 'include {real_conf}\\nTLSType=tls/none\\n' > {path}", | |
| fatal=True, | |
| ) | |
| return os.path.abspath(path) | |
| @pytest.fixture(scope="module") | |
| def slurmctld_target(): | |
| """The "<host> <port>" probe argument for the slurmctld RPC listener.""" | |
| port = str(atf.properties["slurmctld_port"]).split("-")[0] | |
| return f"{atf.properties['slurmctld_host']} {port}" | |
| @pytest.fixture(scope="module") | |
| def slurmd_target(): | |
| """The "<host> <port>" probe argument for a slurmd RPC listener.""" | |
| node = sorted(atf.get_nodes())[0] | |
| host = atf.get_node_parameter(node, "address") or node | |
| port = atf.get_node_parameter(node, "port") | |
| return f"{host} {port}" | |
| @pytest.fixture(scope="module") | |
| def probe(module_setup): | |
| """Compile the non-TLS probe helper and return its absolute path.""" | |
| source = f"{atf.properties['testsuite_scripts_dir']}/tls_reject_probe.c" | |
| dest = "tls_reject_probe" | |
| atf.compile_against_libslurm(source, dest, full=True, fatal=True) | |
| return os.path.abspath(dest) | |
| @pytest.mark.skipif(atf.get_version("sbin/slurmctld") < (26, 5, 4), reason=REASON) | |
| def test_slurmctld_rejects_non_tls_without_http(slurmctld_target, non_tls_conf, probe): | |
| """A non-TLS client is rejected with HTTP listening disabled.""" | |
| result = atf.run_command( | |
| f"{probe} {slurmctld_target}", | |
| env_vars=f"SLURM_CONF={non_tls_conf}", | |
| fatal=True, | |
| ) | |
| output = result["stdout"] + result["stderr"] | |
| assert ( | |
| "PROBE_RESULT=TLS_REQUIRED" in output | |
| ), f"Expected slurmctld to reject the non-TLS probe, got:\n{output}" | |
| @pytest.mark.skipif(atf.get_version("sbin/slurmctld") < (26, 5, 4), reason=REASON) | |
| def test_slurmctld_accepts_tls_without_http(slurmctld_target, probe): | |
| """Control: the same probe under the real (TLS) config is accepted.""" | |
| result = atf.run_command( | |
| f"{probe} {slurmctld_target}", | |
| fatal=True, | |
| ) | |
| output = result["stdout"] + result["stderr"] | |
| assert ( | |
| "PROBE_RESULT=ACCEPTED" in output | |
| ), f"Expected slurmctld to accept the valid TLS probe, got:\n{output}" | |
| @pytest.mark.skipif(atf.get_version("sbin/slurmd") < (26, 5, 4), reason=REASON) | |
| def test_slurmd_rejects_non_tls_without_http(slurmd_target, non_tls_conf, probe): | |
| """A non-TLS client is rejected by slurmd with HTTP listening disabled.""" | |
| result = atf.run_command( | |
| f"{probe} {slurmd_target}", | |
| env_vars=f"SLURM_CONF={non_tls_conf}", | |
| fatal=True, | |
| ) | |
| output = result["stdout"] + result["stderr"] | |
| assert ( | |
| "PROBE_RESULT=TLS_REQUIRED" in output | |
| ), f"Expected slurmd to reject the non-TLS probe, got:\n{output}" | |
| @pytest.mark.skipif(atf.get_version("sbin/slurmd") < (26, 5, 4), reason=REASON) | |
| def test_slurmd_accepts_tls_without_http(slurmd_target, probe): | |
| """Control: the same probe under the real (TLS) config reaches slurmd. | |
| slurmd marks REQUEST_PING from_slurmctld, so what a dispatched ping answers | |
| depends on who runs the probe: an unprivileged sender is refused | |
| ESLURM_USER_ID_MISSING at the authorization check, while root or SlurmUser | |
| reaches _rpc_ping() and gets RESPONSE_PING_SLURMD, which | |
| slurm_get_return_code() maps to SLURM_SUCCESS. Accept either; both are only | |
| reachable once the listener has accepted the connection and dispatched the | |
| RPC, which is all this control needs to show. | |
| """ | |
| result = atf.run_command( | |
| f"{probe} {slurmd_target}", | |
| fatal=True, | |
| ) | |
| output = result["stdout"] + result["stderr"] | |
| assert ( | |
| "PROBE_RESULT=ACCEPTED" in output or f"rc={ESLURM_USER_ID_MISSING}" in output | |
| ), f"Expected slurmd to accept and dispatch the TLS probe, got:\n{output}" | |
| @pytest.mark.skipif(atf.get_version("sbin/slurmctld") < (25, 11), reason=REASON) | |
| def test_plaintext_http_not_served(slurmctld_target): | |
| """No plaintext HTTP is served once disable_http is set. | |
| tls.shtml tells admins to set disable_http if the port must not carry | |
| cleartext at all, so pin that it does what it is recommended for. | |
| test_166_1 pins the inverse, that the port serves HTTP without it. | |
| """ | |
| host, port = slurmctld_target.split() | |
| with pytest.raises(requests.exceptions.RequestException): | |
| resp = requests.get(f"http://{host}:{port}/") | |
| pytest.fail( | |
| f"Expected no plaintext HTTP with disable_http, got" | |
| f" {resp.status_code}:\n{resp.text}" | |
| ) |