diff --git a/nix/host-modules/swarm.nix b/nix/host-modules/swarm.nix index ab4d706a..2afbeb2d 100644 --- a/nix/host-modules/swarm.nix +++ b/nix/host-modules/swarm.nix @@ -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 —