From 767a863cf1f944ae7a04ffcb07ce4cbe90a3e24d Mon Sep 17 00:00:00 2001 From: atlas Date: Sun, 13 Sep 2026 11:45:37 +0200 Subject: [PATCH] swarm-nats: grant agents their hive's terminal subject MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- nix/host-modules/swarm-nats.nix | 10 ++++++++++ nix/module-eval.nix | 18 ++++++++++++++++++ 2 files changed, 28 insertions(+) diff --git a/nix/host-modules/swarm-nats.nix b/nix/host-modules/swarm-nats.nix index 341ebb6a..134faf27 100644 --- a/nix/host-modules/swarm-nats.nix +++ b/nix/host-modules/swarm-nats.nix @@ -783,6 +783,16 @@ in "--hive-client-prefix ${lib.escapeShellArg autheliaCfg.hiveClientPrefix}" "--agent-client-suffix ${lib.escapeShellArg autheliaCfg.agentClientSuffix}" "--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 # on the command line only as a **path** — `argv` is diff --git a/nix/module-eval.nix b/nix/module-eval.nix index 4cc04261..a754f806 100644 --- a/nix/module-eval.nix +++ b/nix/module-eval.nix @@ -773,6 +773,24 @@ let 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; } + { + # 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 # nginx and the container share is created there, so a rename that