feat(nix): assert swarm service names live under swarm.domain

Those names are certified by the swarm-services sub-CA, which is
name-constrained to the swarm's own tree. A service hostname outside
it has no issuer in this deployment -- not a misconfiguration that
degrades, one that cannot produce a matching certificate at all.

Asserting it turns "your certs mysteriously don't work" into an eval
failure that names the option to edit. It applies to every
deployment, not only the autoconfigured one: an operator bringing
their own certificates still needs to know which names they are
bringing them for.

The message names the option rather than only the value, which is why
the hostnames are carried as (option, value) pairs and flattened into
serviceDomains afterwards -- flattening drops exactly the provenance
an error message needs.

Guarded on a null swarm.domain so hive-network.nix's required-domain
assertion stays the one that fires for that case.
This commit is contained in:
atlas 2026-08-05 21:27:32 +02:00 committed by mara
commit 93b89fd566

View file

@ -23,6 +23,25 @@
let
cfg = config.services.hyperhive;
swarmCfg = cfg.swarm;
# The service hostnames with the option that set each one, so an
# assertion can name the thing to edit rather than the value to hunt
# for. `serviceDomains` below is the same set flattened; this keeps
# the provenance that flattening drops.
namedServiceDomains = [
{
option = "services.hyperhive.swarm.forge.domain";
value = swarmCfg.forge.domain;
}
{
option = "services.hyperhive.swarm.matrix.gatewayHost";
value = swarmCfg.matrix.gatewayHost;
}
{
option = "services.hyperhive.swarm.authelia.domain";
value = swarmCfg.authelia.domain;
}
];
in
{
options.services.hyperhive.swarm.hives = lib.mkOption {
@ -175,16 +194,48 @@ in
services.hyperhive.swarm.peerHives = lib.filterAttrs (name: _: name != cfg.hiveName) swarmCfg.hives;
services.hyperhive.swarm.serviceDomains = lib.sort (a: b: a < b) (
lib.unique (
lib.filter (d: d != null && d != "") [
swarmCfg.forge.domain
swarmCfg.matrix.gatewayHost
swarmCfg.authelia.domain
]
)
lib.unique (lib.filter (d: d != null && d != "") (lib.map (s: s.value) namedServiceDomains))
);
assertions = [
{
# Every swarm-service name must live under `swarm.domain`.
#
# This is not style. Those names are certified by the
# swarm-services sub-CA (./swarm-ca.nix), which is
# name-constrained; a name outside the swarm's tree has no
# issuer in this deployment at all. Asserting it here turns
# "your certs mysteriously don't work" into an eval failure that
# names the option — and it applies to every deployment, not
# just the all-local one, because the operator bringing their
# own certs needs to know which names they are bringing them
# for.
#
# Guarded on a null `swarm.domain` so the required-domain
# assertion in ./hive-network.nix stays the one that fires for
# that case; two assertions naming the same missing value is
# noise.
assertion =
swarmCfg.domain == null
|| lib.all (s: s.value == null || lib.hasSuffix ".${swarmCfg.domain}" s.value) namedServiceDomains;
message =
let
bad = lib.filter (
s: s.value != null && !lib.hasSuffix ".${swarmCfg.domain}" s.value
) namedServiceDomains;
in
''
Swarm service hostnames must be sub-domains of
services.hyperhive.swarm.domain ("${toString swarmCfg.domain}"):
${lib.concatMapStringsSep "\n" (s: " ${s.option} = \"${s.value}\";") bad}
These names are issued by the swarm-services sub-CA, which is
constrained to the swarm's own tree a name outside it has no
issuer here, and no certificate this deployment can produce
will match it.
'';
}
{
# Guarded on `hiveName != null` so the required-hiveName
# assertion in ./hyperhive.nix is what fires for that case —