swarm: remove swarm.ui.domain, serve the UI on the swarm domain

The UI and the swarm-controller it fronts are one service to a reader and to a
certificate, so a separate option only ever created two spellings of one
address — and the controller's own public URL was built from one of them while
an allow-list entry named the other.

Removed rather than aliased. A config still setting it was pinning a name the
controller never used; an alias would preserve exactly that mismatch, so
`mkRemovedOptionModule` says so and fails the build.

⚠️ The option was TOTAL and `swarm.domain` is NULLABLE. That is not incidental:
`ui.domain` fell back to `swarm.invalid` so hive-network.nix's required-domain
assertion is what an operator sees, rather than a coercion error from a
consumer. Each site keeps that property its own way — swarm-ui binds a local
total (the idiom swarm-otel.nix already uses as `domainBase`), and the three
sites already gated on `deploy.swarm-ui.enable` gain `&& domain != null`, which
is stricter than a placeholder and puts no invented name into rendered config.

One behaviour change worth naming: `swarm.nix`'s certificate SAN list used to
receive the placeholder `swarm.invalid` on a null domain and now receives
nothing. `lib.optional` would otherwise have put a literal `null` in it.

Verified with `checks.module-eval` rather than `nix-instantiate --parse`: parse
proves syntax, and every failure this change could plausibly cause — a dead
binding, a renamed reference — is an evaluation error.
This commit is contained in:
atlas 2026-08-31 18:53:20 +02:00 committed by mara
commit 5aef2d1afc
7 changed files with 43 additions and 44 deletions

View file

@ -361,7 +361,7 @@ it is stored in the queue, not in the daemon.
At startup the controller registers two Forgejo hooks pointing at At startup the controller registers two Forgejo hooks pointing at
itself — a `push` hook on `internal/knowledge` and a `pull_request` hook itself — a `push` hook on `internal/knowledge` and a `pull_request` hook
on the `agent-configs` org, both under on the `agent-configs` org, both under
`https://<swarm.ui.domain>/webhook/forge/`. `https://<swarm.domain>/webhook/forge/`.
The controller **interprets** a delivery and sends hives a specific The controller **interprets** a delivery and sends hives a specific
message — *the knowledge repo changed*, *deploy agent `foo` at rev message — *the knowledge repo changed*, *deploy agent `foo` at rev

View file

@ -17,8 +17,9 @@ the UI is a view onto the controller's state and reaches it over that
daemon's socket, so the host that runs the controller is the host that daemon's socket, so the host that runs the controller is the host that
can serve the UI. A hive that merely *uses* a swarm has nothing to serve. can serve the UI. A hive that merely *uses* a swarm has nothing to serve.
`swarm.ui.domain` defaults to the swarm apex and can be pinned, the same The UI answers on `services.hyperhive.swarm.domain` and nothing else. It
way `swarm.forge.domain` and `swarm.matrix.gatewayHost` can. shares that name with the swarm-controller it fronts — one service to a
reader and to a certificate — so there is no separate option to pin.
> The apex must differ from `services.hyperhive.domain`. The gateway's > The apex must differ from `services.hyperhive.domain`. The gateway's
> default server already answers for the hive domain, and two vhosts > default server already answers for the hive domain, and two vhosts

View file

@ -817,15 +817,13 @@ in
# Not gated on `deploy.swarm-ui.enable`: in a spread deployment the # Not gated on `deploy.swarm-ui.enable`: in a spread deployment the
# forge and the swarm host are different machines, so that # forge and the swarm host are different machines, so that
# per-host flag is false exactly where this entry is needed. # per-host flag is false exactly where this entry is needed.
# `swarm.ui.domain` is the option the controller builds its public
# URL from; it is slated to collapse into `swarm.domain`.
webhook.ALLOWED_HOST_LIST = lib.concatStringsSep "," ( webhook.ALLOWED_HOST_LIST = lib.concatStringsSep "," (
lib.unique ( lib.unique (
[ [
"external" "external"
hyperhiveDomain hyperhiveDomain
] ]
++ lib.optional (swarmDomain != null) config.services.hyperhive.swarm.ui.domain ++ lib.optional (swarmDomain != null) swarmDomain
) )
); );
log.LEVEL = "Warn"; log.LEVEL = "Warn";

View file

@ -41,7 +41,6 @@ let
gatewayCfg = hyperhiveCfg.gateway; gatewayCfg = hyperhiveCfg.gateway;
hyperhiveDomain = hyperhiveCfg.domain; hyperhiveDomain = hyperhiveCfg.domain;
swarmDomain = hyperhiveCfg.swarm.domain; swarmDomain = hyperhiveCfg.swarm.domain;
uiCfg = hyperhiveCfg.swarm.ui;
deployCfg = hyperhiveCfg.deploy; deployCfg = hyperhiveCfg.deploy;
forgeCfg = hyperhiveCfg.swarm.forge; forgeCfg = hyperhiveCfg.swarm.forge;
@ -1399,8 +1398,10 @@ in
# collector is registered — see `metricsRule` above, # collector is registered — see `metricsRule` above,
# which is where the reasoning for both halves lives. # which is where the reasoning for both halves lives.
lib.optional forgeCfg.behindGateway metricsRule lib.optional forgeCfg.behindGateway metricsRule
++ lib.optional deployCfg.swarm-ui.enable { # Also guarded on the domain being set: without it a null
domain = uiCfg.domain; # apex would render a rule matching the string "null".
++ lib.optional (deployCfg.swarm-ui.enable && swarmDomain != null) {
domain = swarmDomain;
subject = [ "group:${operatorGroup}" ]; subject = [ "group:${operatorGroup}" ];
policy = "one_factor"; policy = "one_factor";
}; };

View file

@ -34,7 +34,7 @@ let
natsCfg = config.services.hyperhive.swarm.nats; natsCfg = config.services.hyperhive.swarm.nats;
forgeCfg = config.services.hyperhive.swarm.forge; forgeCfg = config.services.hyperhive.swarm.forge;
uiCfg = config.services.hyperhive.swarm.ui; swarmDomain = config.services.hyperhive.swarm.domain;
# The controller's forge client speaks TLS to `https://${forgeCfg.domain}`, # The controller's forge client speaks TLS to `https://${forgeCfg.domain}`,
# which the gateway serves with a leaf signed by the hive CA — a CA # which the gateway serves with a leaf signed by the hive CA — a CA
@ -130,8 +130,8 @@ let
# the moment to add an explicit `publicUrl` option — not before, because # the moment to add an explicit `publicUrl` option — not before, because
# until then there is exactly one derivable answer and an option would only # until then there is exactly one derivable answer and an option would only
# be a second place to get it wrong. # be a second place to get it wrong.
webhookEnv = lib.optionalAttrs deployCfg.swarm-ui.enable { webhookEnv = lib.optionalAttrs (deployCfg.swarm-ui.enable && swarmDomain != null) {
SWARM_CONTROLLER_PUBLIC_URL = "https://${uiCfg.domain}"; SWARM_CONTROLLER_PUBLIC_URL = "https://${swarmDomain}";
}; };
# Not a secret to deliver — `swarm-authelia-bridge`'s own bearer check # Not a secret to deliver — `swarm-authelia-bridge`'s own bearer check

View file

@ -55,33 +55,32 @@ let
''; '';
swarmCfg = config.services.hyperhive.swarm; swarmCfg = config.services.hyperhive.swarm;
hiveDomain = config.services.hyperhive.domain; hiveDomain = config.services.hyperhive.domain;
# The swarm apex this UI answers on. Total on a null domain (`.invalid`,
# RFC 2606) so `hive-network.nix`'s required-domain assertion is what fires,
# rather than a coercion error from here — same idiom as `swarm-otel.nix`'s
# `domainBase`.
apex = if swarmCfg.domain == null then "swarm.invalid" else swarmCfg.domain;
in in
{ {
# `enable` moved to `services.hyperhive.deploy.swarm-ui.enable` — see # `enable` moved to `services.hyperhive.deploy.swarm-ui.enable` — see
# ./deploy.nix, where it still derives from the controller's own deploy # ./deploy.nix, where it still derives from the controller's own deploy
# toggle for the same reason. What stays here is what the UI IS: its # toggle for the same reason. `domain` is gone too: the UI answers on the
# domain and wiring. # swarm apex, which the swarm already declares once.
imports = [
(lib.mkRemovedOptionModule [ "services" "hyperhive" "swarm" "ui" "domain" ] ''
The swarm UI answers on services.hyperhive.swarm.domain and nothing
else. It shares that name with the swarm-controller it fronts they
are one service to a reader and to a certificate so a second option
only ever created two spellings of one address.
Removed rather than aliased: a config still setting it was pinning a
name the controller's own public URL never used, and silently keeping
the alias would preserve exactly that mismatch.
'')
];
options.services.hyperhive.swarm.ui = { options.services.hyperhive.swarm.ui = {
domain = lib.mkOption {
type = lib.types.str;
default = if swarmCfg.domain == null then "swarm.invalid" else swarmCfg.domain;
defaultText = lib.literalExpression "services.hyperhive.swarm.domain";
example = "swarm.example.com";
description = ''
Host name the swarm UI answers on. Defaults to the swarm apex
itself the swarm's front page is the swarm's name.
An option rather than a hardcoded derivation so a hive can pin a
different name, the same way `swarm.forge.domain` and
`swarm.matrix.gatewayHost` can.
Total on a null swarm domain (`.invalid`, RFC 2606) so the
required-domain assertion is what fires rather than a coercion
error naming this option same reasoning as
`hive-network.nix`'s.
'';
};
package = lib.mkOption { package = lib.mkOption {
type = lib.types.package; type = lib.types.package;
defaultText = lib.literalExpression "hyperhive.packages.\${system}.swarm-ui"; defaultText = lib.literalExpression "hyperhive.packages.\${system}.swarm-ui";
@ -108,16 +107,16 @@ in
# conflict — so the failure would surface as "the dashboard is # conflict — so the failure would surface as "the dashboard is
# sometimes the swarm UI", which is far harder to read than an # sometimes the swarm UI", which is far harder to read than an
# eval failure naming both options. # eval failure naming both options.
assertion = cfg.domain != hiveDomain; assertion = apex != hiveDomain;
message = '' message = ''
services.hyperhive.swarm.ui.domain (${cfg.domain}) must differ services.hyperhive.swarm.domain (${apex}) must differ from
from services.hyperhive.domain (${hiveDomain}) the hive services.hyperhive.domain (${hiveDomain}) the hive domain is
domain is already served by the gateway's default vhost already served by the gateway's default vhost (dashboard +
(dashboard + agent routes), and two vhosts claiming one agent routes), and two vhosts claiming one server_name
server_name silently resolve to whichever nginx picks. silently resolve to whichever nginx picks.
Set services.hyperhive.swarm.domain to a name distinct from Set services.hyperhive.swarm.domain to a name distinct from
this hive's, or pin swarm.ui.domain explicitly. this hive's.
''; '';
} }
]; ];
@ -132,7 +131,7 @@ in
# child of anything the resolver already answers for, so the # child of anything the resolver already answers for, so the
# `/<hive domain>/` rule does not cover it and this record is what # `/<hive domain>/` rule does not cover it and this record is what
# makes the name resolve at all. # makes the name resolve at all.
services.hyperhive.gateway.localNames = [ cfg.domain ]; services.hyperhive.gateway.localNames = [ apex ];
# This UI's own swagger docs, always same-origin (`/api/docs/` below) # This UI's own swagger docs, always same-origin (`/api/docs/` below)
# so — unlike authelia/matrix/forge's entries — this one needs no # so — unlike authelia/matrix/forge's entries — this one needs no
@ -172,8 +171,8 @@ in
# than broken, so they keep `addSSL` and the asymmetry stays local # than broken, so they keep `addSSL` and the asymmetry stays local
# to the vhost whose correctness depends on the scheme. # to the vhost whose correctness depends on the scheme.
# `removeAttrs` because nixos asserts on a vhost declaring both. # `removeAttrs` because nixos asserts on a vhost declaring both.
services.nginx.virtualHosts."${cfg.domain}" = services.nginx.virtualHosts."${apex}" =
(builtins.removeAttrs (gatewayCfg.lib.tlsFor cfg.domain) [ "addSSL" ]) (builtins.removeAttrs (gatewayCfg.lib.tlsFor apex) [ "addSSL" ])
// { // {
forceSSL = true; forceSSL = true;
listen = gatewayCfg.lib.listen; listen = gatewayCfg.lib.listen;

View file

@ -46,7 +46,7 @@ let
# `forge.<apex>` is, and no CA in the hierarchy issues for it # `forge.<apex>` is, and no CA in the hierarchy issues for it
# implicitly. Left out, its vhost falls back to the hive leaf and the # implicitly. Left out, its vhost falls back to the hive leaf and the
# swarm's front page opens with a name mismatch. # swarm's front page opens with a name mismatch.
++ lib.optional deployCfg.swarm-ui.enable swarmCfg.ui.domain ++ lib.optional (deployCfg.swarm-ui.enable && swarmCfg.domain != null) swarmCfg.domain
# Every swarm service that claims a gateway name belongs here, and # Every swarm service that claims a gateway name belongs here, and
# these three were missing it. Membership is what `gateway.lib.tlsFor` # these three were missing it. Membership is what `gateway.lib.tlsFor`
# consults to pick the services leaf over the hive one, so a name # consults to pick the services leaf over the hive one, so a name