blob: 7516850c2a88d72791a62c5cadaf6f405c8c2360 [file]
############################################################################
# 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}"
)