blob: 2f7a9a0826ab881839874fbe4e940d5806874199 [file]
############################################################################
# Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
############################################################################
"""Verify RPC listeners reject non-TLS connections when TLS is required.
Only runs on a TLS-enabled cluster (the -s2n variant). Confirms the
fail-closed behavior from issue 51066: a client connecting without TLS is
rejected with ESLURM_TLS_REQUIRED before its RPC is parsed or dispatched,
rather than being served in cleartext. That the reject really does precede
authorization is pinned separately against slurmd.
The non-TLS client is made with a SLURM_CONF override that includes the real
slurm.conf but sets TLSType=tls/none, pointed at only the client or task
process; control cases use the real config. Each reject case is paired with
a control that runs the same probe against the same listener and differs
only in SLURM_CONF, so a reject is shown to be specific to the missing TLS
rather than a dead listener.
Listeners covered:
* slurmctld and slurmd, which reject via the http_switch path;
* srun's step-launch listener, which rejects via
on_rpc_connection_data() (the client-listener fix), probed from a task
inside the step. sattach opens the same listener, but only srun names
its RPC port to tasks (SLURM_SRUN_COMM_PORT) while sattach's is
indistinguishable from its I/O ports, so srun stands in for both;
* salloc's allocation-message listener, which rejects via the same path,
probed at the ephemeral port read from the process itself. This one is
reject-only: an outside client has no certificate to complete mutual
TLS with it, so no control is possible (see the test for why that is
still conclusive).
swait's listener is covered by test_166_3, which needs a stepmgr cluster.
scrun's is not covered anywhere: it is the only listener whose behavior
this change really alters, since it previously requested no TLS
fingerprinting at all, but it needs an OCI runtime that is not built in the
environments this suite runs in. It is the first listener to add if that
changes.
The reject paths landed in different releases, so each test gates on the
component owning its path rather than a cluster-wide version.
What is rejected is a non-TLS *RPC*, not everything on the port.
slurmctld.8 and slurmd.8 document HTTP as served on the same port unless
CommunicationParameters=disable_http, with TLS optional, so a plaintext
HTTP client is still answered; that boundary is pinned here too.
"""
import os
import re
import pytest
import requests
import atf
# Reasons for the per-component version gates below.
CTLD_REASON = "Issue 51066: slurmctld TLS-required reply added in 25.11"
SLURMD_REASON = "Issue 51066: slurmd TLS-required reply added in 25.11"
SRUN_REASON = "Issue 51066: conmgr client-listener reject added in 26.05.4"
# salloc only moved its allocation-message listener onto conmgr in 26.11, so
# an older salloc has no reject to exercise. Marked xfail rather than skipped
# so an older salloc that does reject is reported (xfail_strict) instead of
# silently passing this gate off as correct.
ALLOCATE_MSG_REASON = (
"Issue 51066: salloc allocation-message listener moved to conmgr in 26.11"
)
# slurm_errno.h, explicitly valued and unchanged since it was added in 25.11.
ESLURM_TLS_REQUIRED = 13000
# slurm_errno.h. What slurmd answers an unprivileged from_slurmctld RPC.
ESLURM_USER_ID_MISSING = 2010
# root and SlurmUser pass slurmd's authorization check, so a ping from them is
# answered rather than refused and cannot show where the check sits.
PRIVILEGED_TEST_USER = (
atf.properties["test-user-uid"] == 0
or atf.properties["test-user"] == atf.properties["slurm-user"]
)
PRIVILEGED_REASON = (
"Issue 51066: needs a test user slurmd refuses, but the suite runs as"
" root or SlurmUser"
)
# slurm_strerror(ESLURM_TLS_REQUIRED), quoted as the admin-facing symptom in
# the Enforcement section of doc/html/tls.shtml.
TLS_REQUIRED_MSG = "TLS missing but required for connection"
@pytest.fixture(scope="module", autouse=True)
def setup():
atf.require_tls()
atf.require_tool("gcc")
# With CommunicationParameters=disable_http the listener is a plain RPC
# listener, so the http_switch reply the slurmctld legs assert is never
# reached and the reject comes from the conmgr fix instead. A slurmctld
# satisfying CTLD_REASON but predating that fix accepts the plaintext
# probe. test_166_2 covers that configuration.
atf.require_config_parameter_excludes("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.
Only the process pointed at this via SLURM_CONF speaks plaintext; srun and
the daemons keep the real (TLS) configuration.
Returned absolute: the tests consuming this run in their own per-function
directory, and the srun legs read it from inside a job step.
"""
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.
SlurmctldPort is reported as "first-last" when SlurmctldPortCount > 1, so
take the first port rather than handing the range to the probe.
"""
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.
Read per node rather than from SlurmdPort, since a NodeName line may
override the port and the suite's clusters commonly do. These are the
live "scontrol show nodes --json" field names.
"""
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 path.
Internal RPC helpers live in libslurmfull; internal headers need the source
tree on the include path.
Returned absolute for the same reason as non_tls_conf, and because the
srun legs exec it as a task.
"""
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.fixture(scope="module")
def pmi_probe(module_setup):
"""Compile a minimal libpmi client and return its absolute path.
libpmi lives in the install lib dir rather than next to libslurmfull, and
that dir is lib64 on distributions that split it.
"""
prefix = atf.properties["slurm-prefix"]
lib_dir = "lib64" if os.path.isfile(f"{prefix}/lib64/libpmi.so") else "lib"
source = f"{atf.properties['testsuite_scripts_dir']}/pmi_tls_probe.c"
dest = "pmi_tls_probe"
atf.compile_against_libslurm(
source,
dest,
build_args=f"-Wl,-rpath={prefix}/{lib_dir} -L{prefix}/{lib_dir} -lpmi",
full=True,
fatal=True,
)
return os.path.abspath(dest)
# --------------------------------------------------------------------------- #
# slurmctld listener (http_switch reject path)
# --------------------------------------------------------------------------- #
@pytest.mark.skipif(atf.get_version("sbin/slurmctld") < (25, 11), reason=CTLD_REASON)
def test_slurmctld_rejects_non_tls(slurmctld_target, non_tls_conf, probe):
"""A non-TLS client is rejected by slurmctld's RPC listener.
The probe compares the numeric return code against ESLURM_TLS_REQUIRED,
so this does not depend on the wording of the error message.
"""
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") < (25, 11), reason=CTLD_REASON)
def test_slurmctld_accepts_tls(slurmctld_target, probe):
"""Control: the same probe under the real (TLS) config is accepted.
Differs from the reject case only in SLURM_CONF, so an ACCEPTED result
shows the reject is caused by the missing TLS and not by the probe.
"""
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/slurmctld") < (25, 11), reason=CTLD_REASON)
def test_sinfo_rejected_without_tls(non_tls_conf):
"""A documented client command cannot reach slurmctld without TLS.
The probe legs assert the exact return code over an internal API; this
asserts the same contract as a user meets it. sinfo reports the errno it
got, so it pins the message tls.shtml tells admins to look up; scontrol
ping only reports the controller as DOWN and would pass on any failure.
"""
result = atf.run_command("sinfo", env_vars=f"SLURM_CONF={non_tls_conf}", xfail=True)
assert (
TLS_REQUIRED_MSG in result["stderr"]
), f"Expected sinfo to report the TLS-required error, got:\n{result}"
@pytest.mark.skipif(atf.get_version("sbin/slurmctld") < (25, 11), reason=CTLD_REASON)
def test_sinfo_accepted_with_tls():
"""Control: the same command under the real (TLS) config succeeds.
Distinguishes "rejected for want of TLS" from "controller is down".
"""
result = atf.run_command("sinfo")
assert (
result["exit_code"] == 0
), f"Expected sinfo to succeed with TLS, got:\n{result}"
@pytest.mark.skipif(atf.get_version("sbin/slurmctld") < (25, 11), reason=CTLD_REASON)
def test_plaintext_http_still_served():
"""Requiring TLS for RPCs does not close plaintext HTTP on the same port.
slurmctld.8 promises HTTP on SlurmctldPort with TLS wrapping optional and
without requiring TLSType, so this pins the documented exception to the
rejection asserted above. atf.request_slurmctld() speaks plain http://.
Requests the endpoint index rather than a liveness probe: slurmctld.8
documents "GET /" as returning the list of endpoints, so asserting on the
body shows the port still serves response content in cleartext, not just
that something answers the socket. That is what tls.shtml means when it
tells admins to set disable_http if the port must not carry cleartext.
The endpoints that return cluster data would demonstrate it more sharply,
but /metrics* needs MetricsType configured and /{data_parser}/conf and
readyz?verbose need a token, neither of which this module otherwise
requires.
"""
resp = atf.request_slurmctld("")
assert (
resp.ok
), f"Expected plaintext HTTP to still be served, got {resp.status_code}"
assert (
"/healthz" in resp.text
), f"Expected the endpoint index to be served in cleartext, got:\n{resp.text}"
# --------------------------------------------------------------------------- #
# slurmd listener (http_switch reject path)
# --------------------------------------------------------------------------- #
@pytest.mark.skipif(atf.get_version("sbin/slurmd") < (25, 11), reason=SLURMD_REASON)
def test_slurmd_rejects_non_tls(slurmd_target, non_tls_conf, probe):
"""A non-TLS client is rejected by slurmd's RPC listener.
slurmd hands its listening socket to the same http_switch as slurmctld,
so this pins the second daemon tls.shtml names rather than assuming the
two share a code path.
"""
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") < (25, 11), reason=SLURMD_REASON)
def test_slurmd_accepts_tls(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/slurmd") < (25, 11), reason=SLURMD_REASON)
def test_slurmd_plaintext_http_still_served(slurmd_target):
"""slurmd also keeps serving plaintext HTTP on its RPC port.
slurmd.8 documents the same HTTP server and the same "GET /" endpoint
index as slurmctld.8, so the documented exception is pinned for both
daemons rather than assumed to carry over.
"""
host, port = slurmd_target.split()
resp = requests.get(f"http://{host}:{port}/")
assert (
resp.ok
), f"Expected plaintext HTTP to still be served, got {resp.status_code}"
assert (
"/healthz" in resp.text
), f"Expected the endpoint index to be served in cleartext, got:\n{resp.text}"
# --------------------------------------------------------------------------- #
# Ordering of the reject relative to authorization
# --------------------------------------------------------------------------- #
@pytest.mark.skipif(atf.get_version("sbin/slurmd") < (25, 11), reason=SLURMD_REASON)
@pytest.mark.skipif(PRIVILEGED_TEST_USER, reason=PRIVILEGED_REASON)
def test_reject_precedes_authorization(slurmd_target, non_tls_conf, probe):
"""A rejected connection never reaches the listener's authorization check.
tls.shtml promises the connection is closed "before the RPC is parsed,
authenticated, or dispatched". Every other leg here sends a well-formed,
credentialed RPC that the listener would accept, so a listener that
authorized first and rejected afterwards would look identical to one that
rejects first.
slurmd's REQUEST_PING distinguishes them: it is marked from_slurmctld, so
for an unprivileged sender authorize-then-reject answers
ESLURM_USER_ID_MISSING while reject-then-authorize answers
ESLURM_TLS_REQUIRED. The TLS run establishes that this RPC really is one
slurmd refuses at authorization, which is what makes the non-TLS run's
answer evidence of the ordering. That only holds while the probe runs as
a uid slurmd refuses, hence the skip above.
"""
dispatched = atf.run_command(f"{probe} {slurmd_target}", fatal=True)
rejected = atf.run_command(
f"{probe} {slurmd_target}",
env_vars=f"SLURM_CONF={non_tls_conf}",
fatal=True,
)
dispatched_output = dispatched["stdout"] + dispatched["stderr"]
rejected_output = rejected["stdout"] + rejected["stderr"]
assert f"rc={ESLURM_USER_ID_MISSING}" in dispatched_output, (
"Expected slurmd to refuse this RPC at authorization when it is"
f" dispatched, got:\n{dispatched_output}"
)
assert (
"PROBE_RESULT=TLS_REQUIRED" in rejected_output
), f"Expected the non-TLS connection to be rejected, got:\n{rejected_output}"
assert f"rc={ESLURM_USER_ID_MISSING}" not in rejected_output, (
"Expected the non-TLS connection to be rejected before authorization,"
f" but it reached the authorization check:\n{rejected_output}"
)
# --------------------------------------------------------------------------- #
# srun step-launch listener (on_rpc_connection_data reject path)
# --------------------------------------------------------------------------- #
@pytest.mark.skipif(atf.get_version("bin/srun") < (26, 5, 4), reason=SRUN_REASON)
def test_step_launch_rejects_non_tls(non_tls_conf, probe):
"""srun's step-launch listener rejects a non-TLS connection from a task.
Exercises on_rpc_connection_data()'s reject path (the client-listener fix,
shared with the allocate_msg and swait listeners). The probe runs as a task
with the non-TLS config, so it connects back to srun's step-launch listener
(SLURM_SRUN_COMM_HOST/PORT) in plaintext.
"""
result = atf.run_command(
f"srun -N1 -n1 -t1 env SLURM_CONF={non_tls_conf} {probe}",
fatal=True,
)
output = result["stdout"] + result["stderr"]
assert (
"PROBE_RESULT=TLS_REQUIRED" in output
), f"Expected srun to reject the non-TLS probe, got:\n{output}"
@pytest.mark.skipif(atf.get_version("bin/srun") < (26, 5, 4), reason=SRUN_REASON)
def test_step_launch_accepts_tls(probe):
"""Control: a valid TLS task is accepted by srun's step-launch listener.
srun's listener presents a per-allocation certificate that is pinned
through the step launch (not CA-signed). The probe reads that cert from
SLURM_SRUN_TLS_CERT (exported by srun for the task) so it can establish
trusted TLS, the same mechanism libpmi uses to talk back to srun.
"""
result = atf.run_command(
f"srun -N1 -n1 -t1 {probe}",
fatal=True,
)
output = result["stdout"] + result["stderr"]
assert (
"PROBE_RESULT=ACCEPTED" in output
), f"Expected srun to accept the valid TLS probe, got:\n{output}"
@pytest.mark.skipif(atf.get_version("bin/srun") < (26, 5, 4), reason=SRUN_REASON)
def test_pmi_task_rejected_without_tls(non_tls_conf, pmi_probe):
"""A PMI task pointed at a non-TLS config cannot complete its exchange.
This is the reported reproducer rather than a synthetic RPC: libpmi is a
real client of srun's step launch listener, so it exercises the same
rejection through an interface tasks actually use.
"""
result = atf.run_command(
f"srun -N1 -n1 -t1 env SLURM_CONF={non_tls_conf} {pmi_probe}",
xfail=True,
)
output = result["stdout"] + result["stderr"]
assert (
"PMI_RESULT=OK" not in output
), f"Expected the PMI exchange to fail without TLS, got:\n{output}"
assert (
f"error_code={ESLURM_TLS_REQUIRED}" in output
), f"Expected the PMI failure to be the TLS-required error, got:\n{output}"
@pytest.mark.skipif(atf.get_version("bin/srun") < (26, 5, 4), reason=SRUN_REASON)
def test_pmi_task_accepted_with_tls(pmi_probe):
"""Control: the same PMI task under the real (TLS) config completes."""
result = atf.run_command(
f"srun -N1 -n1 -t1 {pmi_probe}",
fatal=True,
)
output = result["stdout"] + result["stderr"]
assert (
"PMI_RESULT=OK" in output
), f"Expected the PMI exchange to succeed with TLS, got:\n{output}"
# --------------------------------------------------------------------------- #
# salloc allocation-message listener (on_rpc_connection_data reject path)
# --------------------------------------------------------------------------- #
# Long enough that salloc is still holding the allocation, with its
# allocation-message listener open, when the probe connects.
SALLOC_HOLD_SECS = 60
# salloc prints this once the allocation is granted; waited for so the probe
# runs against an established allocation rather than a half-started salloc.
GRANTED_RE = re.compile(r"Granted job allocation (\d+)")
def _allocate_msg_target():
"""Return "<host> <port>" for salloc's listening TCP socket, or None.
salloc's allocation-message listener is the only socket it keeps in LISTEN
while running a command, and its ephemeral port is never reported to a
client, so it is read from salloc's own sockets. lsof matches by command
name, not PID, because atf runs the backgrounded salloc under a bash
wrapper whose PID is the one the caller sees.
"""
listeners = atf.run_command_output(
"lsof -a -c salloc -iTCP -sTCP:LISTEN -P -n -Fn", quiet=True
)
for line in listeners.splitlines():
if not line.startswith("n"):
continue
host, _, port = line[1:].rpartition(":")
host = host.strip("[]")
if host in ("*", "", "0.0.0.0", "::"):
host = atf.properties["slurmctld_host"]
return f"{host} {port}"
return None
@pytest.fixture
def salloc_listener():
"""Hold an allocation with salloc and yield its allocate_msg "<host port>".
salloc runs a local command for the life of the allocation, keeping the
allocation-message listener open, and its ephemeral port is read from
salloc's own sockets. salloc is terminated when the test finishes,
including when it fails first.
"""
atf.require_tool("lsof")
log = "salloc.out"
process = atf.run_command(
f"salloc -N1 sleep {SALLOC_HOLD_SECS} > {log} 2>&1",
background=True,
)["process"]
job_id = None
target = None
for _ in atf.timer(fatal=True):
log_output = atf.run_command_output(f"cat {log}", quiet=True)
if process.poll() is not None:
pytest.fail(f"salloc exited before opening its listener:\n{log_output}")
if job_id is None:
granted = GRANTED_RE.search(log_output)
if granted:
job_id = granted[1]
target = _allocate_msg_target()
if job_id and target:
break
yield target
process.terminate()
@pytest.mark.xfail(atf.get_version("bin/salloc") < (26, 11), reason=ALLOCATE_MSG_REASON)
def test_allocate_msg_rejects_non_tls(non_tls_conf, probe, salloc_listener):
"""A non-TLS client is rejected by salloc's allocation-message listener.
No valid-TLS control is probed: an outside client has no certificate to
complete mutual TLS with salloc's listener, and salloc sends no reply to a
bare REQUEST_PING even over TLS. TLS_REQUIRED is proof enough on its own,
since only a live listener enforcing TLS returns it; one that was down or
unreachable would report NO_RESPONSE.
"""
result = atf.run_command(
f"{probe} {salloc_listener}",
env_vars=f"SLURM_CONF={non_tls_conf}",
fatal=True,
)
output = result["stdout"] + result["stderr"]
assert (
"PROBE_RESULT=TLS_REQUIRED" in output
), f"Expected salloc's listener to reject the non-TLS probe, got:\n{output}"