Watch
0
0
Fork
You've already forked hyperhive
0

swarm-nats: fetch the callout xkey before the server starts and seal callout traffic

swarm-bao-nats-callout-xkey runs on the queue's host before the
swarm-nats container, under the responder's store leaf: it runs
`swarm-nats-auth ensure-xkey`, reads the public half back from the
store, and writes it where the container's runtime config dir is. On a
change it restarts the server.

nats-server now always starts through the runtime wrapper, in manual
mode too. A privileged ExecStartPre renders auth_callout with the xkey
(and, in auto mode, the two generated nkeys) at every start, and fails
the server when no xkey has been fetched. The auto-mode key unit now
only mints seeds and writes their public halves.

The responder is wanted by nats.service, so a server started late, once
its key arrived, brings the responder up with it.
This commit is contained in:
atlas 2026-10-05 21:25:40 +02:00 • committed by mara
commit 9a241a1df1
4 changed files with 362 additions and 48 deletions

View file

@ -56,6 +56,7 @@ neither is a renaming of the other.
| authelia subject store | `swarmctl` and `swarm-authelia-bridge` | `users.yml` — one file, read and written by both | `swarmctl`, on the host that runs authelia |
| wireguard private key | **the operator** — `wg genkey` | whatever `deploy.wireguard.privateKeyFile` names | always operator-provided; nothing generates this for you |
| queue auth-callout nkeys (user seed + account seed) | `swarm-nats-callout-keys` first-boot unit, when the operator sets `deploy.nats.autoGenerateCallout` | `/var/lib/swarm-nats-callout/{callout-user,issuer}.seed`, `0600` | operator mints both with `nk` and names them in `deploy.nats.calloutUserSeedFile` / `deploy.nats.calloutIssuerSeedFile` |
| queue auth-callout xkey (curve seed) | `swarm-nats-auth ensure-xkey`, run by `swarm-bao-nats-callout-xkey` on the queue's host when the store holds no seed | the swarm secret store: the seed at `swarm/services/swarm-nats/callout/xkey-seed`, read by the `swarm-nats-auth` role alone; the public half at `swarm/public/swarm-nats/callout-xkey` | nothing to place beyond the responder's store leaf (`deploy.nats.authBaoClientCertFile`) |
| the secret store's own contents | openbao, on first `bao operator init` — **an operator action, not a unit** | inside the `swarm-bao` container, at its own `/var/lib/openbao`, kept across rebuilds by `ephemeral = false`. ⚠️ **Not a host path**: `nixos-container destroy swarm-bao` takes the raft data with it, so back up the container's tree, not `/var/lib/`. Only the store's TLS material (`/var/lib/swarm-bao-tls`) and its PKCS11 token (`/var/lib/swarm-bao-token`) are host-level | n/a — there is one store; a hive elsewhere is a _client_ of it and holds none of this |
| the secret store's unseal material | the HSM/TPM under `deploy.bao.seal = "pkcs11"`; openbao itself under `"shamir"` | in the token; or held by whoever ran `bao operator init`, which is what `"shamir"` means and why it's stated rather than inferred | n/a — only the host running the store seals anything |
@ -146,10 +147,50 @@ used to pass eval and leave the queue with an auth-callout nobody answers, which
refuses every client rather than degrading — and a refusal reaches the client as
a timeout, so the symptom is every consumer hanging with nothing logged.
One consequence of the generated path worth knowing before you debug it: with
`autoGenerateCallout` set, the queue assembles its config at boot rather than at
build time, so a malformed one surfaces when the container starts instead of
when the system builds. The server names the offending file and refuses to run.
One consequence worth knowing before you debug it: the queue assembles its
config each time the server starts rather than at build time, in every mode,
because the callout xkey below exists only at runtime. A malformed config
surfaces when the server starts instead of when the system builds. The server
names the offending file and refuses to run.
### The queue's callout xkey
The server seals every auth-callout request to a curve key, the callout
xkey, and the responder seals its reply back, so nothing else in the callout
account or on the wire can read the request or the user JWT minted for it.
You configure nothing about the key: it lives in the swarm secret store and
comes into being on the queue's first boot.
- **Seed** at `secret/swarm/services/swarm-nats/callout/xkey-seed`. Only the
`swarm-nats-auth` role reads it, and it may create the path but never
replace it.
- **Public half** at `secret/swarm/public/swarm-nats/callout-xkey`. Every token
the store issues may read `swarm/public/`; see
[`bao.md`](bao.md#what-every-token-may-read).
On the queue's host, `swarm-bao-nats-callout-xkey` runs before the
`swarm-nats` container, under the responder's store leaf:
1. `swarm-nats-auth ensure-xkey` creates the pair when the store holds no
seed, and otherwise only reads it. It needs no queue, so on a first boot
the key exists before the server that needs it.
2. It reads the public half back from the store into
`/var/lib/nats-callout/callout-xkey.pub` inside the container.
3. Each time `nats-server` starts, it renders that key into
`auth_callout.xkey`. With no file, the server doesn't start.
4. The responder starts after the server and reads the seed from the store.
A responder that can't read it doesn't start, rather than answering
requests it can't open.
When the store is down at boot, the fetch unit fails and retries for a day.
The server starts with the key fetched last time, or not at all on a first
boot. The retry that lands the file starts or restarts the server, and the
responder comes up with it.
Two starts that create the key at once can't both win: each write is a
create that fails once the path holds anything, and the start that lost reads
the winner's key back. To replace the key, delete both paths, then restart
`swarm-bao-nats-callout-xkey`.
## Hive-level — one of each per hive

View file

@ -57,6 +57,11 @@ let
here = deployCfg.nats.enable;
policy = "swarm-bao-nats-auth-policy";
};
# ./swarm-nats.nix, under the same leaf.
swarm-bao-nats-callout-xkey = {
here = deployCfg.nats.enable;
policy = "swarm-bao-nats-auth-policy";
};
# ./hive-matrix.nix, under matrix-ctl's leaf.
swarm-bao-matrix-oidc = {
here = deployCfg.matrix.enable;

View file

@ -94,25 +94,27 @@ let
# Upstream's rendered settings, re-rendered with the same generator on the
# same value — the artifact `services.nats` would have used, not a
# transcription. ⚠️ This reference is also the only thing keeping it alive:
# `ExecStart` names a runtime path, so nothing else in the closure names
# the store file, and its string context is what stops it being
# garbage-collected out from under a running server.
# transcription. ⚠️ `renderConfig` below is the only thing naming it:
# `ExecStart` names a runtime path, so that reference is what keeps the store
# file in the closure and stops it being garbage-collected out from under a
# running server.
renderedSettings = natsFormat.generate "nats.conf" (
config.containers.${machine}.config.services.nats.settings
);
# Defined once, rendered twice: into `settings` with the operator's values,
# and into the runtime fragment with placeholders the generator fills in.
# and into the runtime fragment with placeholders filled in at server start
# (the xkey always, the two nkeys in auto mode).
#
# 🪤 Do not inline these and hand-write the fragment instead. Both files are
# loaded and the fragment is the LATER definition, so it wins — a
# hand-written copy would make a future edit to `settings` silently
# ineffective on exactly the hives that use auto mode.
# ineffective.
calloutBlocks =
{
userKey,
issuerKey,
xkey ? null,
}:
{
# Two accounts, and the callout user lives in neither of the accounts
@ -191,7 +193,10 @@ let
issuer = issuerKey;
auth_users = [ userKey ];
account = calloutAccount;
};
}
# The curve key the server seals each request to, and opens the
# reply with. A key that does not parse stops the server starting.
// lib.optionalAttrs (xkey != null) { inherit xkey; };
};
};
@ -200,10 +205,85 @@ let
# fragment is generated rather than transcribed — NATS' config parser
# accepts JSON, and an `include` of it merges (measured).
calloutTemplate = natsFormat.generate "swarm-nats-callout-template.conf" (calloutBlocks {
userKey = "@USER_PUBKEY@";
issuerKey = "@ISSUER_PUBKEY@";
userKey =
if deployCfg.nats.autoGenerateCallout then "@USER_PUBKEY@" else deployCfg.nats.calloutUserPublicKey;
issuerKey =
if deployCfg.nats.autoGenerateCallout then
"@ISSUER_PUBKEY@"
else
deployCfg.nats.calloutIssuerPublicKey;
xkey = "@XKEY_PUBKEY@";
});
# Where the fetch unit leaves the xkey's public half, read from the store at
# `xkeyPublicPath`. In auto mode the nkeys' public halves sit beside it.
xkeyPublicFile = "callout-xkey.pub";
userPublicFile = "callout-user.pub";
issuerPublicFile = "issuer.pub";
# The callout xkey's public half in the store, readable by any token. The
# Rust half is `swarm-nats-auth`'s `xkey::PUBLIC_PATH`; the seed beside it
# is `xkeySeedLeaf` in ./swarm-bao.nix.
xkeyPublicPath = "secret/swarm/public/swarm-nats/callout-xkey";
# Each placeholder in `calloutTemplate`, as a `sed` expression over the
# shell variable `renderConfig` reads its value into.
calloutSubstitutions = [
''-e "s|@XKEY_PUBKEY@|$xkey|g"''
]
++ lib.optionals deployCfg.nats.autoGenerateCallout [
''-e "s|@USER_PUBKEY@|$user_pub|g"''
''-e "s|@ISSUER_PUBKEY@|$issuer_pub|g"''
];
# Renders the server's config as it starts, so the settings it includes are
# always the running system's. Exits non-zero, and so stops the server
# starting, while no xkey has been fetched: the server never runs without
# one. `+` because the server's own sandbox cannot write `runtimeDir`.
renderConfig = pkgs.writeShellScript "swarm-nats-render-config" ''
set -euo pipefail
PATH=${
lib.makeBinPath [
pkgs.coreutils
pkgs.gnugrep
pkgs.gnused
]
}
d=${lib.escapeShellArg runtimeDir}
if [ ! -s "$d/${xkeyPublicFile}" ]; then
echo "${runtimeDir}/${xkeyPublicFile} is missing: swarm-bao-nats-callout-xkey on the host fetches it from the secret store, and the queue does not start without it" >&2
exit 1
fi
xkey="$(cat "$d/${xkeyPublicFile}")"
# A public curve key is X and 55 base32 characters; checked here because
# it is about to become part of a sed expression.
if ! printf '%s' "$xkey" | grep -Eqx 'X[A-Z2-7]{55}'; then
echo "${runtimeDir}/${xkeyPublicFile} does not hold a public xkey" >&2
exit 1
fi
${lib.optionalString deployCfg.nats.autoGenerateCallout ''
user_pub="$(cat "$d/${userPublicFile}")"
issuer_pub="$(cat "$d/${issuerPublicFile}")"
''}
sed ${lib.concatStringsSep " " calloutSubstitutions} ${calloutTemplate} > "$d/callout.conf.tmp"
# Mode BEFORE the rename: a rename publishes whatever the file already
# is. World-readable, because the server reads it as `nats` and
# everything in it is public.
chmod 0444 "$d/callout.conf.tmp"
mv "$d/callout.conf.tmp" "$d/callout.conf"
# Upstream's rendered settings, as a sibling the wrapper can name without
# a leading slash.
ln -sfn ${renderedSettings} "$d/settings.conf"
# The wrapper. Bare filenames — see `runtimeDir`. printf rather than a
# heredoc, whose terminator would depend on nix's indentation stripping.
printf 'include "settings.conf"\ninclude "callout.conf"\n' > "$d/nats.conf.tmp"
chmod 0444 "$d/nats.conf.tmp"
mv "$d/nats.conf.tmp" "$d/nats.conf"
'';
baoCfg = config.services.hyperhive.swarm.bao;
baoDeploy = deployCfg.bao;
@ -305,9 +385,9 @@ in
them from `calloutUserPublicKey` / `calloutIssuerPublicKey`.
A first-boot unit mints both keypairs if absent, keeps the seeds
host-side at `0600`, and writes only the public halves into a
fragment the server reads. Nothing secret is evaluated, so
nothing secret reaches the nix store.
host-side at `0600`, and writes only the public halves where the
server's config is rendered from as it starts. Nothing secret is
evaluated, so nothing secret reaches the nix store.
Leave it off wherever the queue and its clients are not the same
operator's problem: the seeds must reach whoever runs the
@ -744,14 +824,15 @@ in
# A wrapper that includes upstream's rendered settings verbatim
# plus the runtime fragment; rendering the config ourselves
# instead would throw away upstream's `settings`, where the
# reviewed reasoning lives. The generator writes it, because the
# includes must be siblings of the fragment (see `runtimeDir`).
# reviewed reasoning lives. `renderConfig` writes it at every start,
# because the includes must be siblings of the fragment (see
# `runtimeDir`).
#
# `mkForce`: upstream defines ExecStart inside an `mkMerge`, so a
# plain override conflicts rather than wins.
systemd.services.nats.serviceConfig.ExecStart = lib.mkIf deployCfg.nats.autoGenerateCallout (
lib.mkForce "${pkgs.nats-server}/bin/nats-server -c ${runtimeWrapper}"
);
systemd.services.nats.serviceConfig.ExecStart =
lib.mkForce "${pkgs.nats-server}/bin/nats-server -c ${runtimeWrapper}";
systemd.services.nats.serviceConfig.ExecStartPre = "+${renderConfig}";
# The auth-callout responder: the half that lets the server
# above say *yes*. Without it the `auth_callout` block is a
@ -766,7 +847,12 @@ in
description = "swarm queue auth-callout responder";
after = [ "nats.service" ];
requires = [ "nats.service" ];
wantedBy = [ "multi-user.target" ];
# `nats.service` too: a server started late, once its xkey arrived,
# brings the responder up with it.
wantedBy = [
"multi-user.target"
"nats.service"
];
serviceConfig = {
ExecStart = lib.concatStringsSep " " [
"${deployCfg.nats.authPackage}/bin/swarm-nats-auth"
@ -1030,6 +1116,100 @@ in
'';
};
# The callout xkey's public half, from the store into the file
# `renderConfig` reads at every server start. `ensure-xkey` first creates
# the pair when the store holds none; it needs no queue, so on a first boot
# the key exists before the server that requires it, and nothing here
# waits on the responder, which itself waits on the server.
#
# Before the container and wanted by it, the shape `swarm-bao-nats-tls`
# has: a store that comes up later fails this run, the server refuses to
# start without the file (or starts with the one last fetched), and the
# retry that lands it starts or restarts the server. Under the responder's
# own leaf, the one principal that may create the two xkey paths.
systemd.services.swarm-bao-nats-callout-xkey = {
description = "create the swarm queue's callout xkey if absent and fetch its public half for the server";
# As `swarm-bao-nats-auth-oidc`: the store's units exist only where it
# runs.
after = lib.optionals baoDeploy.enable [
"swarm-bao-pki.service"
"container@${baoCfg.machine}.service"
];
wants = lib.optionals baoDeploy.enable [ "container@${baoCfg.machine}.service" ];
requires = lib.optionals baoDeploy.enable [ "swarm-bao-pki.service" ];
before = [ "container@${machine}.service" ];
wantedBy = [
"multi-user.target"
"container@${machine}.service"
];
path = [
deployCfg.nats.authPackage
baoDeploy.package
pkgs.coreutils
pkgs.systemd
];
inherit (storeRetry) startLimitBurst startLimitIntervalSec;
serviceConfig = storeRetry.serviceConfig // {
Type = "oneshot";
RemainAfterExit = true;
SyslogIdentifier = "swarm-bao-nats-callout-xkey";
TimeoutStartSec = 30;
};
environment = {
BAO_ADDR = "https://${baoCfg.domain}:${toString baoCfg.port}";
}
// lib.optionalAttrs authStoreActive {
BAO_CLIENT_CERT = deployCfg.nats.authBaoClientCertFile;
BAO_CLIENT_KEY = deployCfg.nats.authBaoClientKeyFile;
}
// lib.optionalAttrs (baoDeploy.serverCaFile != null) {
BAO_CACERT = baoDeploy.serverCaFile;
};
script =
if !authStoreActive then
''
echo "the swarm queue responder has no store identity, so the callout xkey can be neither created nor fetched and the queue does not start: set services.hyperhive.deploy.nats.authBaoClientCertFile and authBaoClientKeyFile" >&2
exit 1
''
else
''
set -euo pipefail
${atomicWriteSecret}
${refreshConsumer}
swarm-nats-auth ensure-xkey --store-cert-role ${lib.escapeShellArg authCertRole}
err="$(mktemp)"
trap 'rm -f "$err"' EXIT
if ! BAO_TOKEN="$(bao login -method=cert -token-only 2>"$err")"; then
echo "could not log in to swarm-bao with the queue responder's certificate; leaving the callout xkey file as it is." >&2
cat "$err" >&2
exit 1
fi
export BAO_TOKEN
if ! xkey="$(bao kv get -field=value ${lib.escapeShellArg xkeyPublicPath} 2>"$err")"; then
echo "swarm-bao did not return ${xkeyPublicPath}." >&2
cat "$err" >&2
exit 1
fi
if [ -z "$xkey" ]; then
echo "swarm-bao returned an empty ${xkeyPublicPath}." >&2
exit 1
fi
install -d -m 0755 ${lib.escapeShellArg hostRuntimeDir}
if secret_differs ${lib.escapeShellArg "${hostRuntimeDir}/${xkeyPublicFile}"} "$xkey"; then
atomic_write_secret 0444 "" ${lib.escapeShellArg "${hostRuntimeDir}/${xkeyPublicFile}"} "$xkey"
fi
# `renderConfig` reads the file as the server starts, and only then.
refresh_consumer ${lib.escapeShellArg machine} nats.service ${lib.escapeShellArg "${hostRuntimeDir}/${xkeyPublicFile}"}
'';
};
# ⚠️ Minted on the HOST, not in the container, because the responder is
# a separate unit that needs the user seed: generating it inside would
# trap it there and require a secret-export path back out — the exact
@ -1070,33 +1250,18 @@ in
issuer_pub="$(nk -inkey ${lib.escapeShellArg autoIssuerSeed} -pubout)"
# Public halves only — world-readable on purpose, since the server
# publishes them to every client that connects.
# publishes them to every client that connects. `renderConfig` puts
# them in the server's config. Mode BEFORE the rename: a rename
# publishes whatever the file already is.
install -d -m 0755 ${lib.escapeShellArg hostRuntimeDir}
sed -e "s|@USER_PUBKEY@|$user_pub|g" \
-e "s|@ISSUER_PUBKEY@|$issuer_pub|g" \
${calloutTemplate} > ${lib.escapeShellArg hostRuntimeDir}/callout.conf.tmp
# Mode BEFORE the rename: a rename publishes whatever the file
# already is, so setting it afterwards leaves a window where the
# live path has the wrong mode. Same trap as the gateway's
# atomic-publish path.
chmod 0444 ${lib.escapeShellArg hostRuntimeDir}/callout.conf.tmp
mv ${lib.escapeShellArg hostRuntimeDir}/callout.conf.tmp \
${lib.escapeShellArg hostRuntimeDir}/callout.conf
# Upstream's rendered settings, as a sibling the wrapper can name
# without a leading slash. Refreshed unconditionally — unlike the
# seeds, this one MUST track the current system, and a stale copy
# would silently run yesterday's config.
ln -sfn ${renderedSettings} ${lib.escapeShellArg hostRuntimeDir}/settings.conf
# The wrapper. Bare filenames — see `runtimeDir`. printf rather than
# a heredoc, whose terminator would depend on nix's indentation
# stripping and break the next time `nix fmt` touched this block.
printf 'include "settings.conf"\ninclude "callout.conf"\n' \
> ${lib.escapeShellArg hostRuntimeDir}/nats.conf.tmp
chmod 0444 ${lib.escapeShellArg hostRuntimeDir}/nats.conf.tmp
mv ${lib.escapeShellArg hostRuntimeDir}/nats.conf.tmp \
${lib.escapeShellArg hostRuntimeDir}/nats.conf
printf '%s\n' "$user_pub" > ${lib.escapeShellArg "${hostRuntimeDir}/${userPublicFile}"}.tmp
printf '%s\n' "$issuer_pub" > ${lib.escapeShellArg "${hostRuntimeDir}/${issuerPublicFile}"}.tmp
chmod 0444 ${lib.escapeShellArg "${hostRuntimeDir}/${userPublicFile}"}.tmp \
${lib.escapeShellArg "${hostRuntimeDir}/${issuerPublicFile}"}.tmp
mv ${lib.escapeShellArg "${hostRuntimeDir}/${userPublicFile}"}.tmp \
${lib.escapeShellArg "${hostRuntimeDir}/${userPublicFile}"}
mv ${lib.escapeShellArg "${hostRuntimeDir}/${issuerPublicFile}"}.tmp \
${lib.escapeShellArg "${hostRuntimeDir}/${issuerPublicFile}"}
'';
};

View file

@ -102,6 +102,109 @@ 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;
}
{
# What keeps a callout request inside one principal: the responder is
# the callout account's only member, and no system account exists to
# observe it from. The runtime fragment is rendered by the same
# `calloutBlocks`, so the count is the same there. The account name is
# the control that the assertions read the callout block at all.
name = "the callout account has exactly one member and no system account is configured";
ok =
lib.all
(
h:
let
settings = h.containers.swarm-nats.config.services.nats.settings;
in
settings.authorization.auth_callout.account == "AUTH"
&& lib.length settings.accounts.AUTH.users == 1
&& !(settings ? system_account)
)
[
natsOldPath
natsOnStoreHost
];
}
{
# The xkey exists only at runtime, so the server's config is rendered as
# it starts: always the wrapper, and never the store-rendered settings
# alone, which carry no xkey. `+` so the render may write `runtimeDir`.
name = "the queue's server starts only through the config rendered with its xkey";
ok =
lib.all
(
h:
let
c = h.containers.swarm-nats.config;
nats = c.systemd.services.nats.serviceConfig;
pre = nats.ExecStartPre or "";
in
lib.hasSuffix "/bin/nats-server -c /var/lib/nats-callout/nats.conf" nats.ExecStart
&& lib.hasPrefix "+/nix/store/" pre
&& lib.hasSuffix "-swarm-nats-render-config" pre
&& !(c.services.nats.settings.authorization.auth_callout ? xkey)
)
[
natsOldPath
natsOnStoreHost
];
}
{
# A server that starts late, once its xkey arrives, must bring the
# responder with it: the responder requires the server, so a server that
# failed to start left the responder inactive rather than failed.
name = "starting the queue's server starts its responder";
ok = lib.elem "nats.service" (responderOf natsOldPath).wantedBy;
}
{
# The key is created before anything needs it, by the responder's own
# verb under its own leaf, then read back from the public path and left
# where the server's render reads it. Before the container and wanted by
# it, and a fetch that finds nothing fails so `storeRetry` retries.
name = "the callout xkey is created, then fetched from the store before the queue's container starts";
ok =
let
u = natsWithStoreIdentity.systemd.services.swarm-bao-nats-callout-xkey;
s = builtins.unsafeDiscardStringContext u.script;
at = needle: lib.stringLength (lib.head (lib.splitString needle s));
ensure = "swarm-nats-auth ensure-xkey --store-cert-role swarm-nats-auth";
fetch = "bao kv get -field=value secret/swarm/public/swarm-nats/callout-xkey";
file = "/var/lib/nixos-containers/swarm-nats/var/lib/nats-callout/callout-xkey.pub";
in
lib.elem "container@swarm-nats.service" u.before
&& lib.elem "container@swarm-nats.service" u.wantedBy
&& u.environment.BAO_CLIENT_CERT == "/etc/pki/nats-auth.pem"
&& lib.hasInfix ensure s
&& lib.hasInfix fetch s
&& at ensure < at fetch
&& lib.hasInfix "atomic_write_secret 0444 \"\" ${file}" s
&& lib.hasInfix "refresh_consumer swarm-nats nats.service ${file}" s
&& !(lib.hasInfix "exit 0" s)
&& u.serviceConfig.Restart == "on-failure";
}
{
name = "a queue host with no responder leaf renders an xkey fetch that fails naming the options";
ok =
let
u = natsOldPath.systemd.services.swarm-bao-nats-callout-xkey;
in
!(u.environment ? BAO_CLIENT_CERT)
&& lib.hasInfix "deploy.nats.authBaoClientCertFile" u.script
&& lib.hasInfix "exit 1" u.script
&& !(lib.hasInfix "ensure-xkey" u.script);
}
{
# Its creates are what the responder's role was granted; on the store's
# host the role's unit has to have run first.
name = "on the store's host the callout xkey fetch waits for the responder's role";
ok =
let
u = natsOnStoreHost.systemd.services.swarm-bao-nats-callout-xkey;
in
u.environment.BAO_CLIENT_CERT == "/var/lib/swarm-bao-pki/nats-auth.pem"
&& lib.elem "swarm-bao-nats-auth-policy.service" u.after
&& lib.elem "swarm-bao-nats-auth-policy.service" u.wants;
}
{
# The responder refuses the hive's shared agent client and takes no flag
# granting it subjects: an `--agent-publish-subject` fails the unit at