swarm-nats: grant agents their hive's terminal subject

The responder has had an agent arm since the principal was minted, but
no deployment ever passed `--agent-publish-subject`, and an empty list
is a refusal by design: `Policy::permissions` returns `None` rather than
a grant that can do nothing, so every agent was turned away at CONNECT.
The subject itself is the one this thread settled on, `$SWARM.term`
namespaced per hive.

The value has to reach the responder with a literal dollar. systemd
substitutes `$NAME` in `ExecStart` whether or not the word is quoted,
so a single dollar expands `SWARM` — unset, therefore empty — and the
responder is handed `.term.{hive}.>`. That grant validates (it carries
the `{hive}` placeholder), is accepted, and matches nothing any agent
publishes to, so the failure surfaces as an authorization violation far
from its cause. `$$` in the unit text is the escape for one dollar.

The module-eval case reads the rendered unit rather than the module
source, because the single-dollar version renders perfectly well; the
doubled dollar is the only thing that distinguishes them before deploy.

The grant is per-hive, not per-agent: an agent's identity names its
hive, so any agent in a hive can publish as another. That is the
tradeoff ruled acceptable for now, tracked separately for tightening.

Refs #3805
This commit is contained in:
atlas 2026-09-13 11:45:37 +02:00
commit 767a863cf1
2 changed files with 28 additions and 0 deletions

View file

@ -783,6 +783,16 @@ in
"--hive-client-prefix ${lib.escapeShellArg autheliaCfg.hiveClientPrefix}" "--hive-client-prefix ${lib.escapeShellArg autheliaCfg.hiveClientPrefix}"
"--agent-client-suffix ${lib.escapeShellArg autheliaCfg.agentClientSuffix}" "--agent-client-suffix ${lib.escapeShellArg autheliaCfg.agentClientSuffix}"
"--reader-client ${lib.escapeShellArg controllerCfg.queueClientId}" "--reader-client ${lib.escapeShellArg controllerCfg.queueClientId}"
# What an agent may publish to, `{hive}` standing for the
# hive its client id names. Without it the responder has no
# agent grant to hand out and refuses every agent at CONNECT,
# so the terminal stream below depends on this line existing.
#
# `$$`, not `$`: systemd substitutes `$NAME` in `ExecStart`
# whether or not it is quoted, so a single dollar reaches the
# responder as the empty expansion of an unset `SWARM` and the
# grant silently becomes `.term.{hive}.>`.
"--agent-publish-subject ${lib.escapeShellArg "\$\$SWARM.term.{hive}.>"}"
]; ];
# Every credential arrives by `LoadCredential` and is named # Every credential arrives by `LoadCredential` and is named
# on the command line only as a **path** — `argv` is # on the command line only as a **path** — `argv` is

View file

@ -773,6 +773,24 @@ let
name = "the queue's payload ceiling is set, not inherited from the server's default"; name = "the queue's payload ceiling is set, not inherited from the server's default";
ok = natsOldPath.containers.swarm-nats.config.services.nats.settings.max_payload == 8388608; ok = natsOldPath.containers.swarm-nats.config.services.nats.settings.max_payload == 8388608;
} }
{
# Reads the RENDERED unit text, not the module's source, because the
# failure this defends against renders perfectly: systemd substitutes
# `$NAME` in `ExecStart` regardless of quoting, so a single dollar
# here hands the responder `.term.{hive}.>` — a grant that parses, is
# accepted, and matches nothing an agent ever publishes to. Asserting
# the doubled dollar is the only way to tell the two apart before
# deploy. The flag's presence is asserted separately so that dropping
# the grant entirely fails as its own arm rather than as an escaping
# complaint.
name = "the responder grants agents their hive's terminal subject, and the dollar survives systemd";
ok =
let
exec =
natsOldPath.containers.swarm-nats.config.systemd.services.swarm-nats-auth.serviceConfig.ExecStart;
in
lib.hasInfix "--agent-publish-subject " exec && lib.hasInfix "$$SWARM.term.{hive}.>" exec;
}
{ {
# Reads the host's tmpfiles rules, not the options: the socket directory # Reads the host's tmpfiles rules, not the options: the socket directory
# nginx and the container share is created there, so a rename that # nginx and the container share is created there, so a rename that