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

@ -817,15 +817,13 @@ in
# Not gated on `deploy.swarm-ui.enable`: in a spread deployment the
# forge and the swarm host are different machines, so that
# 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 "," (
lib.unique (
[
"external"
hyperhiveDomain
]
++ lib.optional (swarmDomain != null) config.services.hyperhive.swarm.ui.domain
++ lib.optional (swarmDomain != null) swarmDomain
)
);
log.LEVEL = "Warn";

View file

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

View file

@ -34,7 +34,7 @@ let
natsCfg = config.services.hyperhive.swarm.nats;
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}`,
# 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
# until then there is exactly one derivable answer and an option would only
# be a second place to get it wrong.
webhookEnv = lib.optionalAttrs deployCfg.swarm-ui.enable {
SWARM_CONTROLLER_PUBLIC_URL = "https://${uiCfg.domain}";
webhookEnv = lib.optionalAttrs (deployCfg.swarm-ui.enable && swarmDomain != null) {
SWARM_CONTROLLER_PUBLIC_URL = "https://${swarmDomain}";
};
# Not a secret to deliver — `swarm-authelia-bridge`'s own bearer check

View file

@ -55,33 +55,32 @@ let
'';
swarmCfg = config.services.hyperhive.swarm;
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
{
# `enable` moved to `services.hyperhive.deploy.swarm-ui.enable` — see
# ./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
# domain and wiring.
# toggle for the same reason. `domain` is gone too: the UI answers on the
# 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 = {
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 {
type = lib.types.package;
defaultText = lib.literalExpression "hyperhive.packages.\${system}.swarm-ui";
@ -108,16 +107,16 @@ in
# conflict — so the failure would surface as "the dashboard is
# sometimes the swarm UI", which is far harder to read than an
# eval failure naming both options.
assertion = cfg.domain != hiveDomain;
assertion = apex != hiveDomain;
message = ''
services.hyperhive.swarm.ui.domain (${cfg.domain}) must differ
from services.hyperhive.domain (${hiveDomain}) the hive
domain is already served by the gateway's default vhost
(dashboard + agent routes), and two vhosts claiming one
server_name silently resolve to whichever nginx picks.
services.hyperhive.swarm.domain (${apex}) must differ from
services.hyperhive.domain (${hiveDomain}) the hive domain is
already served by the gateway's default vhost (dashboard +
agent routes), and two vhosts claiming one server_name
silently resolve to whichever nginx picks.
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
# `/<hive domain>/` rule does not cover it and this record is what
# 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)
# 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
# to the vhost whose correctness depends on the scheme.
# `removeAttrs` because nixos asserts on a vhost declaring both.
services.nginx.virtualHosts."${cfg.domain}" =
(builtins.removeAttrs (gatewayCfg.lib.tlsFor cfg.domain) [ "addSSL" ])
services.nginx.virtualHosts."${apex}" =
(builtins.removeAttrs (gatewayCfg.lib.tlsFor apex) [ "addSSL" ])
// {
forceSSL = true;
listen = gatewayCfg.lib.listen;

View file

@ -46,7 +46,7 @@ let
# `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
# 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
# these three were missing it. Membership is what `gateway.lib.tlsFor`
# consults to pick the services leaf over the hive one, so a name