swarm: default every queue URL to the queue's name on every hive

A remote hive dialled nothing until an operator copied the queue's URL
into it, though the URL is the same string everywhere. statusPublish.natsUrl,
queue.agentNatsUrl and controller.queue.natsUrl now default to
tls://<swarm.nats.domain>:<port> unconditionally.

The statusPublish assertion treated a URL without a secret as a half
config. With the URL a default on every hive, only the secret claims
publishing: the assertion now refuses a secret without a URL or token
endpoint, and hive-c0re's status environment is gated on the secret too,
so a hive without one publishes nothing instead of reading a missing
credential.
This commit is contained in:
atlas 2026-09-24 16:40:26 +02:00 • committed by mara
commit a5259146dc
8 changed files with 220 additions and 129 deletions

View file

@ -338,10 +338,8 @@ nothing has to re-publish.
### Making a hive report
Three options, on the **hive**, set together or not at all — a
half-configured hive is an eval error rather than one that quietly never
reports. They sit in two namespaces, because two of them are facts about
_this machine_ and one is the swarm's single address:
Three options on the **hive**. They sit in two namespaces, because two of them
are facts about _this machine_ and one is the swarm's single address:
| option | what to set it to |
| ------------------------------------------------------- | --------------------------------------------- |
@ -349,10 +347,14 @@ _this machine_ and one is the swarm's single address:
| `swarm.statusPublish.tokenEndpoint` | the swarm IdP's `/api/oidc/token` |
| `deploy.hive-controller.statusPublish.clientSecretFile` | path to this hive's client secret, plaintext |
On a host that runs the queue and the IdP itself, all three default to
the local ones and there is nothing to set. Any other hive needs them
spelled out, and needs the secret to physically be there: the swarm does
not distribute it. Copy `hive-<hiveName>.secret` out of the swarm host's
The queue URL and the token endpoint default to the swarm's own addresses on
every hive, so there is nothing to set for them. The secret is what turns
publishing on: a hive without it doesn't publish. A secret without the other
two is an eval error rather than a hive that quietly never reports.
On a host that runs the queue and the IdP, the secret defaults to the local one.
Any other hive needs the secret to physically be there, because the swarm
doesn't distribute it. Copy `hive-<hiveName>.secret` out of the swarm host's
`deploy.authelia.hostClientSecretDir` with whatever secret management the
deployment already uses.
@ -390,11 +392,12 @@ with the credential itself and aren't configurable.
| `deploy.hive-controller.queue.agentNatsUrl` | where the queue listens, as an agent **container** reaches it |
| `swarm.statusPublish.tokenEndpoint` | the swarm IdP's `/api/oidc/token` — the same one the hive uses |
On a host that runs the queue, `agentNatsUrl` defaults to the same
`tls://<swarm.nats.domain>:<swarm.nats.port>` as the hive's own. Inside a
container the name resolves to the bridge address, where the firewall opens the
port. ⚠️ **Never a loopback address here**: an agent has its own network
namespace, so `127.0.0.1` reaches the agent.
On every hive, `agentNatsUrl` defaults to the same
`tls://<swarm.nats.domain>:<swarm.nats.port>` as the hive's own. On the queue's
host the name resolves inside a container to the bridge address, where the
firewall opens the port; on any other hive it resolves through the host's DNS,
like the store's name. ⚠️ **Never a loopback address here**: an agent has its
own network namespace, so `127.0.0.1` reaches the agent.
The harness sees four variables, and treats them as all-or-none:
`HIVE_AGENT_NATS_URL` and `HIVE_AGENT_OIDC_TOKEN_ENDPOINT` from the two options

View file

@ -133,12 +133,12 @@ in
./options.nix
./theme.nix
# Hive-CA trust for this daemon's outbound TLS. Nothing it is given by
# default is https — the forge, matrix and queue URLs all resolve to
# plain http or loopback — so this changes nothing on an all-local
# hive. It matters for the split-host shape the options invite:
# `swarm.matrix.apiUrl`'s own example is `https://matrix.example.com`,
# and pointing it (or `deploy.hive-controller.statusPublish.natsUrl`)
# at another hive's
# default is https — the forge and matrix URLs resolve to plain http or
# loopback, and the queue's TLS is verified against
# `HIVE_C0RE_OIDC_CA_FILE` (../hive-tls.nix), not this bundle — so this
# changes nothing on an all-local hive. It matters for the split-host
# shape the options invite: `swarm.matrix.apiUrl`'s own example is
# `https://matrix.example.com`, and pointing it at another hive's
# gateway means verifying a leaf signed by a CA generated at runtime,
# which no build-time trust store can contain.
#

View file

@ -257,24 +257,30 @@ in
# environment is a deployment bug the daemon refuses to treat as
# "no queue configured", because the failure it would otherwise
# produce is a hive that comes up fine and silently never reports.
# The three-option version of that same rule is asserted at eval in
# ./../swarm.nix, so this can only ever emit a complete set.
# The rule that a secret needs the other two options is asserted at
# eval in ./../swarm.nix, so this can only ever emit a complete set.
#
# ⚠️ The guard and the value beside it read different namespaces on
# purpose: where the queue is and where its secret sits are this
# machine's, the token endpoint is the swarm's one address. The
# assertion covers all three, which is what keeps the guard honest.
lib.optionalAttrs (config.services.hyperhive.deploy.hive-controller.statusPublish.natsUrl != null) {
HIVE_C0RE_NATS_URL = config.services.hyperhive.deploy.hive-controller.statusPublish.natsUrl;
HIVE_C0RE_OIDC_TOKEN_ENDPOINT = config.services.hyperhive.swarm.statusPublish.tokenEndpoint;
# The identity swarm-authelia.nix already declares for every entry in
# `swarm.hives` — the hive does not choose its own name here, it uses
# the one the roster gave it.
HIVE_C0RE_OIDC_CLIENT_ID = "hive-${config.services.hyperhive.hiveName}";
# `%d` is systemd's credentials directory — see the LoadCredential in
# ./default.nix. The daemon reads a path, never a value.
HIVE_C0RE_OIDC_CLIENT_SECRET_FILE = "%d/swarm-status-client.secret";
}
# ⚠️ Gated on the SECRET as well as the URL. The URL defaults to the
# queue's name on every hive, so on its own it says where the queue is,
# not that this hive publishes; the secret is that claim. Gated on the
# URL alone, a hive with no secret would be handed a secret path that
# `LoadCredential` in ./default.nix never fills.
lib.optionalAttrs
(
config.services.hyperhive.deploy.hive-controller.statusPublish.natsUrl != null
&& config.services.hyperhive.deploy.hive-controller.statusPublish.clientSecretFile != null
)
{
HIVE_C0RE_NATS_URL = config.services.hyperhive.deploy.hive-controller.statusPublish.natsUrl;
HIVE_C0RE_OIDC_TOKEN_ENDPOINT = config.services.hyperhive.swarm.statusPublish.tokenEndpoint;
# The identity swarm-authelia.nix already declares for every entry in
# `swarm.hives` — the hive does not choose its own name here, it uses
# the one the roster gave it.
HIVE_C0RE_OIDC_CLIENT_ID = "hive-${config.services.hyperhive.hiveName}";
# `%d` is systemd's credentials directory — see the LoadCredential in
# ./default.nix. The daemon reads a path, never a value.
HIVE_C0RE_OIDC_CLIENT_SECRET_FILE = "%d/swarm-status-client.secret";
}
// {
# Where ../glue-queue-agent-credential.nix lands the AGENTS' queue
# credential. Read by `hive_c0re::lifecycle::host_config`, which stats the

View file

@ -86,31 +86,13 @@ in
config.services.hyperhive.deploy.nats.autoGenerateCallout =
lib.mkDefault cfg.deploy.singleHostSwarm;
# ⚠️ Every `swarm.*` default this mode sets lives INSIDE this attrset, not
# as a second `config.services.hyperhive.swarm.…` path beside it — written
# that way the two definitions of `swarm` collide and the nested one is
# silently lost. The gate caught exactly that on the controller's queue URL,
# when this mode still set it: mode on, `natsUrl` still "".
config.services.hyperhive.swarm = {
ca.autoConfigure = lib.mkDefault cfg.deploy.singleHostSwarm;
# The controller's queue coordinates. Kept with the mode, not in the
# options' own `default`, because the controller's minted client secret
# only exists on the host authelia ran its first boot on — so they belong
# to the mode that asserts this box is the whole deployment.
#
# Deriving them from `deploy.nats` / `deploy.authelia`
# inside those defaults is the mixing this file exists to prevent: the
# option would be describing a deployment shape instead of describing
# itself, and "what does all-local turn on?" would stop having one
# answer.
#
# ⚠️ Must live INSIDE this attrset, not as a second
# `config.services.hyperhive.swarm.…` path beside it — written that
# way the two definitions of `swarm` collide and the nested one is
# silently lost. The gate caught exactly that: mode on, `natsUrl`
# still "".
#
# The *requirement* stays in `swarm-controller.nix` as an assertion:
# needing a queue is the controller's own property in every topology,
# and only the convenience is local.
controller.queue.natsUrl = lib.mkIf cfg.deploy.singleHostSwarm (
lib.mkDefault "tls://${config.services.hyperhive.swarm.nats.domain}:${toString config.services.hyperhive.swarm.nats.port}"
);
};
# The controller is asserted by the MODE and by nothing else. Its own

View file

@ -17,6 +17,7 @@ let
cfg = config.services.hyperhive.swarm.controller;
deployCfg = config.services.hyperhive.deploy;
autheliaCfg = config.services.hyperhive.swarm.authelia;
natsCfg = config.services.hyperhive.swarm.nats;
# Where the secret store is, and whether this host holds the controller's
# own leaf for it. ⚠️ The controller's pair, NOT `deploy.bao.clientCertFile`
@ -364,18 +365,18 @@ in
queue = {
natsUrl = lib.mkOption {
type = lib.types.str;
default = "";
default = "tls://${natsCfg.domain}:${toString natsCfg.port}";
defaultText = lib.literalExpression ''"tls://''${swarm.nats.domain}:''${swarm.nats.port}"'';
example = "tls://nats.example.com:4222";
description = ''
Where the controller reaches the swarm queue.
Defaults to the queue's name, which is the same URL on every
host: the queue's own host resolves it locally, and any other
through the operator's DNS.
Empty means unset, which the assertion below refuses — a
controller with no queue is not a lighter controller.
`singleHostSwarm` fills this in with
`tls://<swarm.nats.domain>:<port>`. That derivation lives with the
mode rather than here, so this option describes itself rather than
a deployment shape.
'';
};
@ -764,8 +765,8 @@ in
message = ''
services.hyperhive.swarm.controller.queue.natsUrl is unset.
`singleHostSwarm` fills it in with the queue's name. A controller
in any other deployment has to be told the URL.
It defaults to the queue's name; something in this configuration
set it to "". Remove that setting, or set the URL.
'';
}
{

View file

@ -115,16 +115,15 @@ let
lib.filterAttrs (_: hive: hive.certFingerprint != null) swarmCfg.hives
);
# Whether the queue and its minted secret are on THIS host — the two
# coordinates that are a statement about this machine's disk and
# netns, from one condition so a partial set is unrepresentable rather
# than merely detected.
# Whether this hive's minted status secret is on THIS host, which is the
# one status-publishing coordinate that is a statement about this
# machine's disk.
#
# ⚠️ The token endpoint is NOT one of them any more. It is the swarm's
# one address, derived from `swarm.authelia.url` like
# ./swarm-controller.nix's own `queue.tokenEndpoint` already is, so it
# is correct for a remote provider and does not ask where anything
# runs.
# ⚠️ Neither the token endpoint nor the queue's URL is gated on it. Both
# are the swarm's one address: the endpoint derived from
# `swarm.authelia.url`, the queue from its name, which resolves on every
# hive. So they are correct wherever the queue and the IdP run, and do
# not ask where anything runs.
queueLocal = deployCfg.nats.enable && deployCfg.authelia.enable && cfg.hiveName != null;
in
{
@ -373,18 +372,16 @@ in
}
{
# Deliberately an assertion and not a silent "then publish
# nothing": a half-set trio is a config an operator believes is
# working, and its runtime failure mode is the expensive one —
# the daemon comes up fine, never connects, and the hive reads
# `never_reported` on a dashboard nobody is watching yet.
# nothing": a secret without the coordinates it is presented at is
# a config an operator believes is working, and its runtime failure
# mode is the expensive one — the daemon comes up fine, never
# connects, and the hive reads `never_reported` on a dashboard
# nobody is watching yet.
#
# Safe to add to an existing deployment: every `statusPublish`
# default is either all-local or all-null, so no config that
# evaluates today can be caught by this. It also encodes a
# property of the code rather than an intended shape — hive-c0re
# genuinely cannot publish with two of three coordinates — which
# is the distinction the `serviceDomains'` comment at the top of
# this file was written about.
# It encodes a property of the code rather than an intended shape —
# hive-c0re genuinely cannot publish with two of three coordinates —
# which is the distinction the `serviceDomains'` comment at the top
# of this file was written about.
#
# The three coordinates live in two namespaces now: the token
# endpoint is the swarm's one address, the other two are this
@ -392,23 +389,21 @@ in
# operator told only the option names would look for them under
# one prefix and find one of them.
#
# ⚠️ Asymmetric on purpose. A token endpoint on its own is the
# normal state of every hive in a swarm that has an IdP — it says
# where the IdP is, not that this hive publishes. Only the two
# per-host coordinates are a claim to be publishing, and they
# need the endpoint to mean anything.
# ⚠️ Asymmetric on purpose. The token endpoint and the queue's URL
# both default to the swarm's one address on every hive: they say
# where the IdP and the queue are, not that this hive publishes, so
# either without the secret is publishing off rather than a half-set
# trio. Only the client secret is a claim to be publishing, and it
# needs the other two to mean anything.
assertion =
let
local = lib.filter (v: v != null) [
deployCfg.hive-controller.statusPublish.natsUrl
deployCfg.hive-controller.statusPublish.clientSecretFile
];
in
builtins.length local == 0
|| (builtins.length local == 2 && swarmCfg.statusPublish.tokenEndpoint != null);
deployCfg.hive-controller.statusPublish.clientSecretFile == null
|| (
deployCfg.hive-controller.statusPublish.natsUrl != null
&& swarmCfg.statusPublish.tokenEndpoint != null
);
message = ''
This hive's status-publishing coordinates have to be set
together or not at all — it has only some of them.
This hive has a status-publishing client secret but not the
coordinates to publish with it.
Currently:
deploy.hive-controller.statusPublish.natsUrl
@ -419,7 +414,7 @@ in
= ${toString deployCfg.hive-controller.statusPublish.clientSecretFile}
Set the missing ones to publish this hive's status to the
swarm, or set all three to null to turn publishing off.
swarm, or set clientSecretFile to null to turn publishing off.
'';
}
(nameGuards.mustNotEqual {
@ -543,21 +538,22 @@ in
options.services.hyperhive.deploy.hive-controller.statusPublish = {
natsUrl = lib.mkOption {
type = lib.types.nullOr lib.types.str;
default =
if queueLocal then "tls://${swarmCfg.nats.domain}:${toString swarmCfg.nats.port}" else null;
defaultText = lib.literalExpression ''"tls://''${swarm.nats.domain}:''${swarm.nats.port}" when this host runs the queue and the IdP, else null'';
default = "tls://${swarmCfg.nats.domain}:${toString swarmCfg.nats.port}";
defaultText = lib.literalExpression ''"tls://''${swarm.nats.domain}:''${swarm.nats.port}"'';
example = "tls://nats.example.com:4222";
description = ''
Where the swarm queue listens, as seen from *this* hive.
Defaults to the queue's name when this host runs the queue and the
IdP. A hive that is not the swarm host sets the same URL: the name
resolves through the operator's DNS there. TLS only, and by name,
since the queue's certificate carries the name and no address.
Defaults to the queue's name on every hive. The queue's own host
resolves it locally, and any other hive through the operator's
DNS. TLS only, and by name, since the queue's certificate carries
the name and no address.
Null disables status publishing: this hive computes its own
readiness as always, and simply offers it to nobody. The swarm
controller then reports it `never_reported`, which is the honest
A URL alone does not publish.
{option}`services.hyperhive.deploy.hive-controller.statusPublish.clientSecretFile`
is what turns publishing on. Without it this hive computes its own
readiness as always and offers it to nobody, and the swarm
controller reports it `never_reported`, which is the honest
reading.
'';
};
@ -579,6 +575,9 @@ in
readable, and one in the environment is readable by anything
that can open {file}`/proc/<pid>/environ`.
Setting it is what makes this hive publish its status; null
turns publishing off.
Defaults to authelia's own minted secret when the IdP runs on
this host. On any other hive the secret has to get here somehow,
and the swarm does not distribute it — copy it out of the swarm
@ -598,25 +597,24 @@ in
# owns `queue.agentCredentialDir` in the same namespace.
options.services.hyperhive.deploy.hive-controller.queue.agentNatsUrl = lib.mkOption {
type = lib.types.nullOr lib.types.str;
default =
if queueLocal then "tls://${swarmCfg.nats.domain}:${toString swarmCfg.nats.port}" else null;
defaultText = lib.literalExpression ''"tls://''${swarm.nats.domain}:''${swarm.nats.port}" when this host runs the queue and the IdP, else null'';
default = "tls://${swarmCfg.nats.domain}:${toString swarmCfg.nats.port}";
defaultText = lib.literalExpression ''"tls://''${swarm.nats.domain}:''${swarm.nats.port}"'';
example = "tls://nats.example.com:4222";
description = ''
Where the swarm queue listens, as an agent *container* on this host
reaches it.
Defaults to the queue's name when this host runs the queue. The agent
resolves it through the bridge, where dnsmasq answers it with the
bridge address. ⚠️ Never a loopback address: inside an agent's network
namespace `127.0.0.1` is the agent, not this host, and the queue's
certificate names no address anyway.
Defaults to the queue's name on every hive. The agent resolves it
through the bridge: on the queue's own host dnsmasq answers it with
the bridge address, and elsewhere it forwards to the operator's DNS,
as for the store's name. ⚠️ Never a loopback address: inside an
agent's network namespace `127.0.0.1` is the agent, not this host,
and the queue's certificate names no address anyway.
Null means this hive's agents have not been given the queue's address.
Together with
{option}`services.hyperhive.swarm.statusPublish.tokenEndpoint` it is
what decides whether the harness is handed queue coordinates at all; a
hive whose queue is elsewhere names the address its containers route to.
what decides whether the harness is handed queue coordinates at all.
'';
};

View file

@ -42,6 +42,10 @@ let
# renders the same absences `bare` does.
centralToggleOff = hive { enable = false; };
# Every hive defaults the agents' queue address to the queue's name, so the
# only hive without one is a hive told to have none.
noAgentQueue = hive { deploy.hive-controller.queue.agentNatsUrl = null; };
withCi = hive { deploy.forgejo.ci.enable = true; };
# A priority collision is a property of the *option*, not
@ -149,13 +153,13 @@ let
}
{
# The absence arm, and what makes the two above able to fail: a hive
# with no queue address must forward neither coordinate, because half a
# pair reaches the harness as a partial configuration rather than as
# none.
name = "a hive with no swarm queue forwards no agent queue coordinates";
# with no queue address for its agents must forward neither coordinate,
# because half a pair reaches the harness as a partial configuration
# rather than as none.
name = "a hive with no agent queue address forwards no agent queue coordinates";
ok =
let
e = bare.systemd.services.hive-c0re.environment;
e = noAgentQueue.systemd.services.hive-c0re.environment;
in
!(e ? HIVE_AGENT_NATS_URL) && !(e ? HIVE_AGENT_OIDC_TOKEN_ENDPOINT);
}

View file

@ -47,6 +47,40 @@ let
deploy.nats.autoGenerateCallout = true;
};
# A hive that is not the queue's host, with nothing about the queue's
# address set by hand: what every hive but one in a multi-host swarm looks
# like. The controller is on too, since it may run away from the queue.
#
# The two secrets are the ones a hive away from authelia already has to be
# handed, and neither is an address; without them this hive would fail
# assertions that have nothing to do with the queue.
remoteSecrets = {
deploy.forgejo.sso.clientSecretFile = "/var/lib/forgejo-oidc/by-hand.secret";
deploy.swarm-controller.queue.clientSecretFile = "/var/lib/secrets/swarm-controller.secret";
};
remote = hive (lib.recursiveUpdate remoteSecrets { deploy.swarm-controller.enable = true; });
# The same hive given its status secret by hand, which is what turns
# publishing on away from the IdP's host.
remotePublishing = hive (
lib.recursiveUpdate remoteSecrets {
deploy.hive-controller.statusPublish.clientSecretFile = "/var/lib/secrets/hive-h1.secret";
}
);
# A real half-config: the secret, with the URL it would be presented at
# taken away.
remoteSecretNoUrl = hive (
lib.recursiveUpdate remoteSecrets {
deploy.hive-controller.statusPublish.clientSecretFile = "/var/lib/secrets/hive-h1.secret";
deploy.hive-controller.statusPublish.natsUrl = null;
}
);
failedAssertions = m: lib.filter (a: !a.assertion) m.assertions;
refusedStatusSecret =
m: lib.any (a: lib.hasInfix "status-publishing client secret" a.message) (failedAssertions m);
policyScript = allLocal.systemd.services.swarm-bao-nats-tls-policy.script;
leafUnit = allLocal.systemd.services.swarm-bao-nats-tls;
natsContainer = allLocal.containers.swarm-nats.config;
@ -78,6 +112,8 @@ let
containerUnits = lib.concatLists (
lib.mapAttrsToList (c: cc: fromUnits c cc.config.systemd.services) machine.containers
);
# The responder runs beside the queue only, so a hive without one has
# none to read.
responder =
map
(u: {
@ -85,7 +121,9 @@ let
value = u;
})
(
urlsIn machine.containers.swarm-nats.config.systemd.services.swarm-nats-auth.serviceConfig.ExecStart
lib.optionals (machine.containers ? swarm-nats) (
urlsIn machine.containers.swarm-nats.config.systemd.services.swarm-nats-auth.serviceConfig.ExecStart
)
);
in
lib.listToAttrs (fromUnits "host" machine.systemd.services ++ containerUnits ++ responder);
@ -212,6 +250,65 @@ let
!(lib.elem "swarm-bao-nats-tls-policy.service" u.after)
&& !(lib.elem "swarm-bao-nats-tls-policy.service" u.wants);
}
{
# Every hive dials the queue by the same name, so a hive away from it
# needs no URL of its own. Control and property in one: the scan must
# reach the controller and the agents' address here, and every URL it
# finds, like the options it reads through, is the name.
name = "a hive that is not the queue's host dials tls://<the queue's name>:4222 with nothing set";
ok =
let
s = clientUrls remote;
d = remote.services.hyperhive.deploy;
in
!d.nats.enable
&& s ? "host/hive-c0re/HIVE_AGENT_NATS_URL"
&& s ? "host/swarm-controller/SWARM_CONTROLLER_NATS_URL"
&& lib.all (u: u == natsUrl) (lib.attrValues s)
&& d.hive-controller.statusPublish.natsUrl == natsUrl
&& d.hive-controller.queue.agentNatsUrl == natsUrl
&& remote.services.hyperhive.swarm.controller.queue.natsUrl == natsUrl;
}
{
# The URL is set and the secret is not, which is every such hive until
# an operator places one: publishing is off, not misconfigured.
name = "that hive evaluates without an assertion failure";
ok = failedAssertions remote == [ ];
}
{
# Off means off: hive-c0re is handed no status coordinates, rather
# than a secret path nothing fills.
name = "without its status secret, that hive's hive-c0re is given no status coordinates";
ok =
let
s = remote.systemd.services.hive-c0re;
in
!(s.environment ? HIVE_C0RE_NATS_URL)
&& !(s.environment ? HIVE_C0RE_OIDC_CLIENT_SECRET_FILE)
&& !(lib.any (lib.hasPrefix "swarm-status-client.secret:") (
lib.toList (s.serviceConfig.LoadCredential or [ ])
));
}
{
# The secret alone turns publishing on, at the default URL.
name = "given its status secret, that hive publishes to the queue's name";
ok =
let
s = remotePublishing.systemd.services.hive-c0re;
in
failedAssertions remotePublishing == [ ]
&& s.environment.HIVE_C0RE_NATS_URL == natsUrl
&& s.environment.HIVE_C0RE_OIDC_CLIENT_SECRET_FILE == "%d/swarm-status-client.secret"
&& lib.elem "swarm-status-client.secret:/var/lib/secrets/hive-h1.secret" (
lib.toList s.serviceConfig.LoadCredential
);
}
{
# What the assertion was written for is still refused: a secret with
# nowhere to present it.
name = "a status secret without a queue URL is refused at eval";
ok = refusedStatusSecret remoteSecretNoUrl && !(refusedStatusSecret remote);
}
];
in
runGroup "nats-tls" cases