Watch
0
0
Fork
You've already forked hyperhive
0

gateway: serve the hive dashboard on <hive>.hives.<swarm> behind authelia, drop htpasswd

The dashboard, its API, the swagger UI and the agent routes move to their
own vhost, `swarm.dashboardDomain` = `<hive>.hives.<swarm.domain>`, under
the swarm domain so authelia's session cookie reaches it. `/`, `/api/` and
`/api/docs/` ask authelia through `auth_request`, and authelia's
`operatorsOnly` rule for each hive's dashboard name lets only the admins
group in. The hive domain keeps matrix discovery and redirects every other
path to the new name.

The name rides as a SAN on the hive's own gateway leaf: the per-hive bao
role allows it, and hive-tls asks for it and checks coverage.

HTTP Basic auth is gone: the htpasswd writer and its test, the
`hivectl gateway` verbs and host-socket requests, the `gateway.auth`
options (now a removed-option error), the unauthorized page and the bcrypt
dependency. tmpfiles deletes a leftover gateway.htpasswd, which held
bcrypt hashes at 0644.

c0re hands out dashboard and agent URLs on the new name
(HYPERHIVE_DASHBOARD_DOMAIN), and the swarm controller's hive roster
carries each hive's dashboard name for the swarm UI's hive list.

Closes #3469
This commit is contained in:
atlas 2026-10-15 20:19:23 +02:00 • committed by mara
commit 92559af8e7
47 changed files with 351 additions and 623 deletions

View file

@ -355,7 +355,7 @@ in
# - nix build worktrees live in PrivateTmp, not /tmp.
# - /etc writes (bind-mount edits) go through hive-priv via the
# privileged socket; /etc/hyperhive/serve.json is read-only.
# - the gateway's nginx include + htpasswd live under
# - the gateway's nginx include lives under
# /var/lib/hive-gateway/conf, deliberately outside StateDirectory
# (see hive-c0re/src/paths.rs::GATEWAY_CONF_DIR) so nginx never
# needs access to the rest of c0re's state — that separation

View file

@ -58,6 +58,11 @@ in
# required (asserted in hive-network.nix), so it's always set.
HYPERHIVE_HIVE_DOMAIN = config.services.hyperhive.domain;
}
// lib.optionalAttrs (config.services.hyperhive.swarm.dashboardDomain != null) {
# The host the dashboard and the agent UIs answer on, for the URLs c0re
# hands out (`hivectl open home`, each agent's UI link).
HYPERHIVE_DASHBOARD_DOMAIN = config.services.hyperhive.swarm.dashboardDomain;
}
// lib.optionalAttrs (config.services.hyperhive.hiveName != null) {
HYPERHIVE_HIVE_NAME = config.services.hyperhive.hiveName;
}

View file

@ -115,6 +115,8 @@ let
nginxTree = import ./vhosts.nix {
gwLib = cfg.lib;
inherit (config.services.hyperhive.swarm) dashboardDomain;
autheliaDomain = config.services.hyperhive.swarm.authelia.domain;
inherit
lib
cfg
@ -236,19 +238,25 @@ in
# /var/lib/hyperhive.
"d /var/lib/hive-gateway 0755 root root - -"
"d /var/lib/hive-gateway/conf 0755 hive-core hive-core - -"
# Pre-created so nginx's config test can open it before c0re has
# ever written: nginx names it, and an `include` of a missing file is
# a fatal config error, not an empty one. tmpfiles runs before
# services, which is the whole ordering guarantee — content arrives
# when c0re writes and reloads, which it does on every topology
# change. `f` = create-if-absent, never overwrite.
"f /var/lib/hive-gateway/conf/agents.conf 0644 hive-core hive-core - # Generated by hive-c0re — do not edit.\n"
# Pre-create both files so nginx's config test can open them before
# c0re has ever written: nginx names them, and an `include` of a
# missing file is a fatal config error, not an empty one. tmpfiles
# runs before services, which is the whole ordering guarantee —
# content arrives when c0re writes and reloads, which it does on
# every topology change. `f` = create-if-absent, never overwrite.
#
# An empty htpasswd causes all auth checks to return 401 (no valid
# credentials), which is the correct no-users behaviour.
"f /var/lib/hive-gateway/conf/gateway.htpasswd 0644 hive-core hive-core - -"
# Nothing reads or writes this path. A copy left on an existing host
# holds bcrypt password hashes at 0644; tmpfiles' `--remove` pass, on
# switch and at boot, deletes it.
"r /var/lib/hive-gateway/conf/gateway.htpasswd"
];
# The dashboard's name, for agents on the bridge and (with
# `localHostsEntry`) the host's own browser.
services.hyperhive.gateway.localNames = lib.optional (
runsHive && config.services.hyperhive.swarm.dashboardDomain != null
) config.services.hyperhive.swarm.dashboardDomain;
# ACME (Let's Encrypt) integration. nginx vhosts set
# `enableACME = true` via the vhost builder; this provides the
# shared ACME config (acceptTerms + email).

View file

@ -67,17 +67,4 @@ in
<p class="hint">Check the container: <code>journalctl -M swarm-authelia -u authelia-swarm</code>. This page recovers on reload once the provider is up.</p>
'';
};
unauthorized = mkPage {
name = "unauthorized";
title = "unauthorized";
accent = "#f38ba8";
body = ''
<p>This hive is protected by HTTP Basic auth. Valid credentials are required.</p>
<p class="hint">Operator: add a user with <code>hivectl gateway create-user</code>:</p>
<pre>hivectl gateway create-user \
&lt;username&gt; --password-stdin</pre>
<p class="hint">Then reload your browser and enter the credentials when prompted.</p>
'';
};
}

View file

@ -18,6 +18,12 @@ in
mode. Remove the setting; configure `tls.certDir` or `tls.acme`
to override the self-signed default.
'')
(lib.mkRemovedOptionModule [ "services" "hyperhive" "gateway" "auth" ] ''
The dashboard is served on services.hyperhive.swarm.dashboardDomain
behind authelia, and only accounts in authelia's `admins` group can
open it. There is no HTTP Basic auth and no htpasswd file. Remove
the setting; add an operator with `swarmctl user add <name> --group admins`.
'')
];
options.services.hyperhive.gateway = {
@ -169,7 +175,7 @@ in
readOnly = true;
description = ''
Read-only: the gateway's styled static error pages, by name
(`notFound`, `unreachable`, `unauthorized`, `ssoUnavailable`).
(`notFound`, `unreachable`, `ssoUnavailable`).
Published so a service module can aim an `error_page` at one
instead of rendering its own — a service that built its own
@ -343,32 +349,6 @@ in
};
};
auth = {
enable = lib.mkEnableOption ''
HTTP basic auth on the gateway using an htpasswd file. When
enabled, every request to the gateway's main vhost requires a
valid username and password. nginx's built-in `auth_basic`
module validates credentials against
`/var/lib/hive-gateway/conf/gateway.htpasswd`. Off by default.
Manage users with `hivectl gateway create-user`, `delete-user`,
and `list-users` — see `hivectl gateway --help` for usage.
The htpasswd file is created automatically when auth is enabled;
add at least one user before enabling to avoid locking everyone out.
'';
realm = lib.mkOption {
type = lib.types.strMatching "[^\"$]*";
default = "hyperhive";
example = "my-hive";
description = ''
HTTP Basic auth `realm` value sent in the `WWW-Authenticate`
header when credentials are absent or rejected. Must not
contain `"` or `$` (nginx string metacharacters).
'';
};
};
swaggerUiTheme = lib.mkOption {
type = lib.types.package;
defaultText = lib.literalExpression "hyperhive.packages.\${system}.swagger-ui-theme";

View file

@ -22,7 +22,7 @@
svcCert, # swarm-services leaf, for names the hive leaf cannot carry
svcKey,
swarmServiceDomains, # which names those are (../swarm.nix derives it)
errorPages, # ./error-pages.nix: { notFound, unreachable, unauthorized, ssoUnavailable }
errorPages, # ./error-pages.nix: { notFound, unreachable, ssoUnavailable }
caBundle, # hive TLS trust bundle on the host
}:
let

View file

@ -1,8 +1,10 @@
# nginx virtual-host tree for the **hive's own** gateway surface: the `_`
# default server, which answers unmatched `Host` with a bare 444, and the
# vhost named for the hive domain, which carries the dashboard, per-agent
# routing, matrix discovery, the swagger UI and the legacy
# `<hive>/matrix/*` redirect.
# default server, which answers unmatched `Host` with a bare 444; the
# dashboard's vhost (`swarm.dashboardDomain`), which carries the dashboard
# behind authelia, per-agent routing and the swagger UI; and the vhost
# named for the hive domain, which carries matrix discovery, the legacy
# `<hive>/matrix/*` redirect and a redirect of everything else to the
# dashboard's name.
#
# Scope: a swarm service declares its own vhost and dns name in its own
# module, built from the kit this file also consumes
@ -18,10 +20,12 @@
matrixCfg, # services.hyperhive.swarm.matrix — what the homeserver IS
matrixDeployCfg, # services.hyperhive.deploy.matrix — what THIS host runs
hyperhiveDomain,
dashboardDomain, # services.hyperhive.swarm.dashboardDomain
autheliaDomain, # services.hyperhive.swarm.authelia.domain
runsHive, # deploy.hive-controller.enable — whether hive-c0re runs here
dashboardDist,
swaggerUiTheme, # nix/packages/swagger-ui-theme.nix: has index.html + hyperhive-theme.css
errorPages, # ./error-pages.nix: { notFound, unreachable, unauthorized, ssoUnavailable }
errorPages, # ./error-pages.nix: { notFound, unreachable, ssoUnavailable }
gwLib, # `services.hyperhive.gateway.lib` — ./vhost-lib.nix's kit, via the option
}:
let
@ -126,15 +130,19 @@ let
};
};
# Shared auth block — separate locations don't inherit auth_basic, so
# each dashboard location (`/`, `/api/`) needs it or that surface is
# unauthed.
dashboardAuth = lib.optionalString cfg.auth.enable ''
auth_basic "${cfg.auth.realm}";
auth_basic_user_file /var/lib/hive-gateway/conf/gateway.htpasswd;
# `=401` keeps the status 401 so the login dialog shows; the
# internal page explains `hivectl gateway create-user`.
error_page 401 =401 /__hive_auth_unauthorized;
# Repeated verbatim by every operator-gated location (`/`, `/api/`,
# `/api/docs/`): nginx's `auth_request` does NOT inherit across sibling
# locations, so a location without it is reachable with no session.
#
# ⚠️ `auth_request` answers "is there a session", not "is this an
# operator". authelia's `operatorsOnly` rule for this name
# (../swarm-authelia.nix) is what refuses an agent's own account.
dashboardAuth = ''
auth_request /__hive_authelia;
# Captured BEFORE the error_page jump: inside the 401 handler
# `$request_uri` is the internal one.
auth_request_set $target_url $scheme://$http_host$request_uri;
error_page 401 =302 https://${autheliaDomain}/?rd=$target_url;
'';
# Dashboard: nginx static-serves the dist, c0re is API-only. Routing
@ -167,8 +175,8 @@ let
'';
};
"/health/" = {
# No dashboardAuth here: an external uptime monitor generally can't do
# interactive HTTP Basic. The endpoints themselves are scoped to
# No dashboardAuth here: an external uptime monitor can't do an
# interactive login. The endpoints themselves are scoped to
# status + warning kind/message (see hive-c0re/src/dashboard/
# health.rs) — no tokens, no agent detail — so exposing them
# unauthenticated isn't a new secret surface.
@ -206,6 +214,29 @@ let
'';
};
};
# The subrequest `dashboardAuth` makes, the same as the swarm UI's
# (../swarm-ui.nix), which documents the header set.
autheliaLocation = {
"= /__hive_authelia" = {
proxyPass = "https://${autheliaDomain}/api/authz/auth-request";
# nixpkgs appends its OWN `Host $host` after extraConfig, which would
# override verifiedProxyTo's — see the comment on verifiedProxyTo in
# ./vhost-lib.nix.
recommendedProxySettings = false;
extraConfig = ''
internal;
${gwLib.verifiedProxyTo autheliaDomain}
proxy_pass_request_body off;
proxy_set_header Content-Length "";
proxy_set_header X-Original-Method $request_method;
proxy_set_header X-Original-URL $scheme://$http_host$request_uri;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Host $http_host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
'';
};
};
in
{
virtualHosts = {
@ -237,32 +268,26 @@ in
'';
};
}
// lib.optionalAttrs runsHive {
# The hive domain's own vhost, reachable by NAME, so the catch-all can
# reject everything else. Only where a hive runs: the dashboard, agent
# routes and swagger UI proxy to hive-c0re, which runs only with the hive
# controller, and the certificate naming the hive domain is the hive
# leaf, which exists only there.
${hyperhiveDomain} = (vhostTlsFor hyperhiveDomain) // {
// lib.optionalAttrs (runsHive && dashboardDomain != null) {
# The dashboard, reachable by its own NAME under the swarm domain, where
# authelia's session cookie reaches. Only where a hive runs: the
# dashboard, agent routes and swagger UI proxy to hive-c0re, which runs
# only with the hive controller, and the hive leaf carrying this name
# exists only there.
#
# The agent routes live here and not on the hive domain because the
# agent page calls the dashboard's `/api/` on its own origin
# (frontend/packages/agent/src/lib/dashboardBase.ts), and the dashboard
# links to them by path. They carry no `auth_request`.
#
# `forceSSL` for the reason ../swarm-ui.nix gives: authelia answers the
# subrequest for an `http://` target with 400, which `auth_request`
# cannot read. `removeAttrs` because nixos asserts on a vhost declaring
# both.
${dashboardDomain} = (builtins.removeAttrs (vhostTlsFor dashboardDomain) [ "addSSL" ]) // {
forceSSL = true;
listen = vhostListen;
locations =
matrixRedirectLocations
// wellKnownLocations
// agentLocations
// dashboardProxyLocation
// swaggerUiLocations
// lib.optionalAttrs cfg.auth.enable {
# Internal-only target for the dashboard's 401 error_page.
# `internal` prevents direct client access; `alias` serves
# the pre-built HTML from the Nix store.
"= /__hive_auth_unauthorized" = {
extraConfig = ''
internal;
alias ${errorPages.unauthorized};
default_type text/html;
'';
};
};
locations = agentLocations // dashboardProxyLocation // swaggerUiLocations // autheliaLocation;
# Per-agent location blocks, generated at runtime by
# hive-c0re and written to /var/lib/hive-gateway/conf/agents.conf
# on the host — the same machine nginx runs on. nginx parses
@ -275,5 +300,21 @@ in
include /var/lib/hive-gateway/conf/agents.conf;
'';
};
# The hive domain's own vhost: matrix discovery, which federation
# reads off the server name, and a redirect of every other path to the
# dashboard's name, so a bookmarked dashboard or agent link still lands.
${hyperhiveDomain} = (vhostTlsFor hyperhiveDomain) // {
listen = vhostListen;
locations =
matrixRedirectLocations
// wellKnownLocations
// {
"/".extraConfig = ''
return 301 ${publicScheme}://${dashboardDomain}${publicPortSuffix}$request_uri;
'';
};
extraConfig = securityHeaders;
};
};
}

View file

@ -63,15 +63,23 @@ let
# The role this hive's gateway leaf is issued through. ./swarm-bao.nix
# writes one per hive in the swarm directory, under the same prefix, and
# narrows it to that hive's domain and `*.` that domain. So the leaf carries
# those two names and no others: a configured service name goes on the
# swarm-services leaf below, and asking this role for one is a refusal.
# narrows it to that hive's domain, `*.` that domain and its dashboard name.
# So the leaf carries those names and no others: a configured service name
# goes on the swarm-services leaf below, and asking this role for one is a
# refusal.
hiveLeafRole =
if hyperhiveCfg.hiveName == null then
null
else
"${baoDeploy.hiveLeafPkiRolePrefix}-${hyperhiveCfg.hiveName}";
# Everything after the first rides as a SAN.
hiveLeafNames = [
domain
"*.${domain}"
]
++ lib.optional (hyperhiveCfg.swarm.dashboardDomain != null) hyperhiveCfg.swarm.dashboardDomain;
showPath = p: if p == null then "unset" else p;
# Fails the unit, naming both options, when an identity is unset or a file
@ -159,7 +167,7 @@ let
leafCoverage = ''
svcleaf="$d/swarm-services.pem"
svcroot="$d/swarm-services-root.pem"
hiveNames=${lib.escapeShellArg "${domain} *.${domain}"}
hiveNames=${lib.escapeShellArg (lib.concatStringsSep " " hiveLeafNames)}
svcNames=${lib.escapeShellArg (lib.concatStringsSep " " localServiceDomains)}
# A host fronting none of the swarm's service names has no services
@ -526,7 +534,7 @@ in
if ! bao write -format=json \
${lib.escapeShellArg "${baoServicesPkiMount}/issue/${toString hiveLeafRole}"} \
common_name=${lib.escapeShellArg domain} \
alt_names=${lib.escapeShellArg "*.${domain}"} \
alt_names=${lib.escapeShellArg (lib.concatStringsSep "," (lib.tail hiveLeafNames))} \
> "$resp" 2>"$err"; then
fail "the store refused to issue the gateway leaf: $(cat "$err")"
fi

View file

@ -12,10 +12,9 @@
# `docs/swarm/sso.md` owns the role model — session provider always, OIDC
# provider derived from the client list — and the secrets map.
#
# Per-service integration — putting authelia's `auth_request` in front
# of the gateway's existing `auth_basic` locations — is deliberately NOT
# here. Standing an SSO provider up is reversible; cutting every
# operator-facing vhost over to it is not.
# Per-service integration — the `auth_request` in front of a vhost —
# lives in the module declaring that vhost. What lives here is the
# `access_control` rule each of them relies on.
{
pkgs,
lib,
@ -799,9 +798,8 @@ in
# STARTUP, so a mismatch is a container that refuses to boot rather
# than a login that misbehaves.
#
# ⚠️ And deliberately NO `dashboardAuth` here. That block is the
# gateway's `auth_basic`; applying it to the SSO provider would put
# the login page behind the login mechanism it exists to replace.
# ⚠️ And deliberately NO `auth_request` here: it would put the login
# page behind the login it serves.
services.nginx.virtualHosts."${cfg.domain}" = (gatewayCfg.lib.tlsFor cfg.domain) // {
listen = gatewayCfg.lib.listen;
extraConfig = gatewayCfg.lib.securityHeaders;
@ -1215,7 +1213,13 @@ in
# one, and without these rules any session passes it.
++ lib.optionals (swarmDomain != null) (operatorsOnly {
domain = hyperhiveCfg.swarm.bao.ui.domain;
});
})
# Every hive's dashboard, each on its own hive's host.
++ lib.optionals (swarmDomain != null) (
lib.concatMap (domain: operatorsOnly { inherit domain; }) (
lib.attrValues hyperhiveCfg.swarm.dashboardDomains
)
);
};
# The cookie domain is the SWARM's domain, NOT authelia's
@ -1227,13 +1231,9 @@ in
# setting both to the same host fails validation at startup
# rather than at first login.
#
# ⚠️ Known and accepted consequence while a hive keeps a
# domain outside the swarm's tree: this cookie is NOT sent
# to that hive's own surfaces (its dashboard), so SSO
# covers the swarm's services and not the hive's. It
# resolves when the hive domain moves under the swarm
# domain; until then it is a scope limit, not a bug to
# chase.
# Each hive's dashboard answers on `swarm.dashboardDomains`,
# under this domain, so the cookie reaches it whatever the
# hive's own domain is.
session.cookies = [
{
domain = cookieDomain;

View file

@ -789,6 +789,7 @@ let
name = "${baoDeploy.hiveLeafPkiRolePrefix}-${hiveName}";
cn = "${baoDeploy.hiveLeafCommonNamePrefix}-${hiveName}";
inherit (hive) domain;
dashboard = hyperhiveCfg.swarm.dashboardDomains.${hiveName};
}) hyperhiveCfg.swarm.hives;
# The PKI mount agent client certificates are issued from. Its root is
@ -3453,14 +3454,15 @@ in
# A SIXTH sibling, the nats unit's shape once per hive: each hive's
# gateway-leaf role, its policy and its login role. The role allows the
# hive's domain and `*.` that domain, and no IP SANs or localhost.
# hive's domain, `*.` that domain and the hive's dashboard name, and no
# IP SANs or localhost.
systemd.services.swarm-bao-hive-leaf-policy =
granterPolicyUnit "write the per-hive gateway-leaf pki roles, bao policies and cert-auth roles"
(
lib.concatMapStrings (obj: ''
bao write ${lib.escapeShellArg "${servicesPkiMountPath}/roles/${obj.name}"} \
allowed_domains=${lib.escapeShellArg "${obj.domain},*.${obj.domain}"} \
allowed_domains=${lib.escapeShellArg "${obj.domain},*.${obj.domain},${obj.dashboard}"} \
allow_bare_domains=true \
allow_subdomains=false \
allow_glob_domains=true \

View file

@ -795,6 +795,7 @@ in
lib.mapAttrsToList (name: h: {
inherit name;
inherit (h) domain;
dashboard = config.services.hyperhive.swarm.dashboardDomains.${name};
}) config.services.hyperhive.swarm.hives
);
# The merged links list — see `links`' description above for who

View file

@ -1,6 +1,6 @@
# The swarm-level web UI: a static bundle served by the gateway's nginx,
# behind authelia. Distinct from the per-hive dashboard (hive-c0re's, on
# the hive domain) — this one answers for the swarm apex and is the
# `swarm.dashboardDomain`) — this one answers for the swarm apex and is the
# operator's view across hives.
#
# Options only. The vhost itself is declared in hive-gateway/vhosts.nix
@ -105,19 +105,20 @@ in
config = lib.mkIf deployCfg.swarm-controller.enable {
assertions = [
{
# The `_` default server already answers for the hive domain
# (dashboard, per-agent routes). A second vhost claiming the same
# The hive domain has its own vhost (matrix discovery, the redirect
# to the dashboard). A second vhost claiming the same
# server_name is not an error to nginx — it picks one and logs a
# conflict — so the failure would surface as "the dashboard is
# sometimes the swarm UI", which is far harder to read than an
# conflict — so the failure would surface as "the swarm UI is
# sometimes a redirect", which is far harder to read than an
# eval failure naming both options.
assertion = apex != hiveDomain;
message = ''
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.
already served by the gateway's own vhost for it (matrix
discovery, the redirect to the dashboard), 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.
@ -154,8 +155,7 @@ in
}
];
# The swarm's front page, and the FIRST `auth_request` anywhere in
# this gateway (everything else is `auth_basic` + htpasswd).
# The swarm's front page.
#
# ⚠️ `auth_request` answers "is there a session", not "is this an
# operator". The operator-only part is authelia's `access_control`

View file

@ -99,6 +99,9 @@ let
# `swarm.nats.domain` is deliberately absent: its `/metrics` vhost names
# the queue's own leaf instead (./swarm-nats.nix), so a host running only
# the queue fronts no services name and never waits for that leaf.
#
# So are the hives' `dashboardDomains`: each is a SAN on its own hive's
# leaf, which that hive issues with the identity it already holds.
serviceDomains' = [
swarmCfg.forge.domain
swarmCfg.matrix.gatewayHost
@ -264,6 +267,31 @@ in
'';
};
options.services.hyperhive.swarm.dashboardDomains = lib.mkOption {
type = lib.types.attrsOf lib.types.str;
readOnly = true;
internal = true;
description = ''
Read-only: each hive's dashboard hostname, keyed by hive name,
`<hive>.hives.<swarm.domain>`. Under the swarm domain so authelia's
session cookie reaches it, whatever the hive's own domain is.
Derived for every hive in `hives`, not only this one: the store's
host writes each hive's leaf role from it, and authelia's host writes
the operator-only rule for all of them.
'';
};
options.services.hyperhive.swarm.dashboardDomain = lib.mkOption {
type = lib.types.nullOr lib.types.str;
readOnly = true;
internal = true;
description = ''
Read-only: this hive's entry in `dashboardDomains`, or null where
`hiveName` has no entry in `hives` (asserted below).
'';
};
options.services.hyperhive.swarm.serviceDomains = lib.mkOption {
type = lib.types.listOf lib.types.str;
readOnly = true;
@ -305,6 +333,19 @@ in
};
config = {
# Total on a null `swarm.domain` for the reason `hives.<name>.domain`'s
# default gives.
services.hyperhive.swarm.dashboardDomains = lib.mapAttrs (
name: _:
if swarmCfg.domain == null then "${name}.hives.invalid" else "${name}.hives.${swarmCfg.domain}"
) swarmCfg.hives;
services.hyperhive.swarm.dashboardDomain =
if cfg.hiveName != null && swarmCfg.dashboardDomains ? ${cfg.hiveName} then
swarmCfg.dashboardDomains.${cfg.hiveName}
else
null;
services.hyperhive.swarm.serviceDomains = lib.sort (a: b: a < b) (
lib.unique (lib.filter (d: d != null && d != "") serviceDomains')
);