From 5aef2d1afc42601972eeb8885b7ce2ec25aaf2cd Mon Sep 17 00:00:00 2001 From: atlas Date: Mon, 31 Aug 2026 18:53:20 +0200 Subject: [PATCH] swarm: remove swarm.ui.domain, serve the UI on the swarm domain MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- docs/swarm/README.md | 2 +- docs/swarm/ui.md | 5 +- nix/host-modules/hive-forge/default.nix | 4 +- nix/host-modules/swarm-authelia.nix | 7 +-- nix/host-modules/swarm-controller.nix | 6 +-- nix/host-modules/swarm-ui.nix | 63 ++++++++++++------------- nix/host-modules/swarm.nix | 2 +- 7 files changed, 44 insertions(+), 45 deletions(-) diff --git a/docs/swarm/README.md b/docs/swarm/README.md index 743568ed..7e5976f4 100644 --- a/docs/swarm/README.md +++ b/docs/swarm/README.md @@ -361,7 +361,7 @@ it is stored in the queue, not in the daemon. At startup the controller registers two Forgejo hooks pointing at itself — a `push` hook on `internal/knowledge` and a `pull_request` hook on the `agent-configs` org, both under -`https:///webhook/forge/`. +`https:///webhook/forge/`. The controller **interprets** a delivery and sends hives a specific message — *the knowledge repo changed*, *deploy agent `foo` at rev diff --git a/docs/swarm/ui.md b/docs/swarm/ui.md index 67d5e3e4..f7c02846 100644 --- a/docs/swarm/ui.md +++ b/docs/swarm/ui.md @@ -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 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 -way `swarm.forge.domain` and `swarm.matrix.gatewayHost` can. +The UI answers on `services.hyperhive.swarm.domain` and nothing else. It +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 > default server already answers for the hive domain, and two vhosts diff --git a/nix/host-modules/hive-forge/default.nix b/nix/host-modules/hive-forge/default.nix index 6863ce03..f80b3abe 100644 --- a/nix/host-modules/hive-forge/default.nix +++ b/nix/host-modules/hive-forge/default.nix @@ -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"; diff --git a/nix/host-modules/swarm-authelia.nix b/nix/host-modules/swarm-authelia.nix index 93a63e71..10f18da0 100644 --- a/nix/host-modules/swarm-authelia.nix +++ b/nix/host-modules/swarm-authelia.nix @@ -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"; }; diff --git a/nix/host-modules/swarm-controller.nix b/nix/host-modules/swarm-controller.nix index 01c8e316..e46f2c2e 100644 --- a/nix/host-modules/swarm-controller.nix +++ b/nix/host-modules/swarm-controller.nix @@ -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 diff --git a/nix/host-modules/swarm-ui.nix b/nix/host-modules/swarm-ui.nix index 1cc72ec2..08068f55 100644 --- a/nix/host-modules/swarm-ui.nix +++ b/nix/host-modules/swarm-ui.nix @@ -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 # `//` 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; diff --git a/nix/host-modules/swarm.nix b/nix/host-modules/swarm.nix index 2fd9d961..0b1016b4 100644 --- a/nix/host-modules/swarm.nix +++ b/nix/host-modules/swarm.nix @@ -46,7 +46,7 @@ let # `forge.` 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