feat(nix): serve swarm-service vhosts the swarm-services leaf

nginx already carried TLS per vhost, so this is a cert choice rather
than a restructure: a vhost whose name this hive's CA cannot sign
gets the swarm-services pair, and every other vhost keeps the hive
pair.

Which vhosts those are is not decided here. Both the sub-CA's name
constraints and this choice read swarm.serviceDomains, so "a swarm
service" means one thing in one place -- a vhost served a cert its
issuer is constrained out of would fail at TLS, and the two lists
drifting is the only way to get there.

Only in self-signed mode. With ACME or an operator cert there is a
single issuer that already covers every name, and a second pair would
be a cert nobody asked for.

The container import copies the pair only when the host issued one,
and removes a stale copy otherwise: the leaf exists only where the
swarm CA is autoconfigured, so absent is a normal state and a
leftover from a host that stopped issuing it is not.
This commit is contained in:
atlas 2026-08-05 21:23:38 +02:00 committed by mara
commit 11b8140981
2 changed files with 54 additions and 2 deletions

View file

@ -15,6 +15,10 @@
let
cfg = config.services.hyperhive.gateway;
hyperhiveDomain = config.services.hyperhive.domain;
# Derived once in ../swarm.nix; the vhosts that get the swarm-services
# cert are exactly the names that cert is issued for, so both read the
# same list rather than each deciding what "a swarm service" means.
swarmServiceDomains = config.services.hyperhive.swarm.serviceDomains;
matrixCfg = config.services.hyperhive.swarm.matrix;
forgeCfg = config.services.hyperhive.swarm.forge;
networkCfg = config.services.hyperhive.network;
@ -263,6 +267,12 @@ in
if cfg.tls.certDir != null then "/run/hive-tls/${cfg.tls.certName}" else "${tlsDir}/cert.pem";
tlsKey =
if cfg.tls.certDir != null then "/run/hive-tls/${cfg.tls.keyName}" else "${tlsDir}/key.pem";
# The swarm-services pair, used only by the vhosts whose names
# this hive's CA cannot sign. Self-signed mode only: with an
# operator cert or ACME the operator owns every name and there
# is no second issuer in the picture.
svcCert = "${tlsDir}/swarm-services.pem";
svcKey = "${tlsDir}/swarm-services-key.pem";
nginxTree = import ./vhosts.nix {
inherit
lib
@ -274,6 +284,9 @@ in
swaggerUiTheme
tlsCert
tlsKey
svcCert
svcKey
swarmServiceDomains
;
errorPages = import ./error-pages.nix { inherit pkgs; };
};
@ -362,6 +375,23 @@ in
# Permission denied`, blocking the unit. Cert is world-read.
install -m 0644 /run/hive-ca/gateway.pem ${tlsCert}
install -m 0640 -g nginx /run/hive-ca/gateway-key.pem ${tlsKey}
# The swarm-services leaf, when this host issues one. It is
# a separate pair rather than more SANs on the one above
# because no hive CA can sign these names — each is
# constrained to its own hive's domain and the service
# names are siblings of it.
#
# Absent is a normal state, not a failure: the leaf exists
# only where the swarm CA is autoconfigured. Copying it
# conditionally keeps a hive whose certs come from its
# operator working unchanged.
if [ -s /run/hive-ca/swarm-services.pem ]; then
install -m 0644 /run/hive-ca/swarm-services.pem ${svcCert}
install -m 0640 -g nginx /run/hive-ca/swarm-services-key.pem ${svcKey}
else
rm -f ${svcCert} ${svcKey}
fi
'';
};

View file

@ -15,6 +15,9 @@
errorPages, # ./error-pages.nix: { notFound, unreachable, unauthorized }
tlsCert,
tlsKey,
svcCert, # swarm-services leaf, for names the hive CA cannot sign
svcKey,
swarmServiceDomains, # which vhosts those are (../swarm.nix derives it)
}:
let
# The gateway always terminates TLS: self-signed is the implicit
@ -51,6 +54,25 @@ let
sslCertificateKey = tlsKey;
};
# TLS attrs for one vhost, by name. A swarm service's name may sit
# outside this hive's domain — and then the hive CA is
# name-constrained out of it, so its vhost must serve the
# swarm-services leaf instead. Everything else keeps the hive leaf.
#
# Only in self-signed mode: with ACME or an operator cert there is a
# single issuer that already covers every name, and a second pair
# would be a cert nobody asked for.
vhostTlsFor =
host:
if !cfg.tls.acme.enable && cfg.tls.certDir == null && builtins.elem host swarmServiceDomains then
{
addSSL = true;
sslCertificate = svcCert;
sslCertificateKey = svcKey;
}
else
vhostTls;
# Public-facing scheme + port-suffix for URLs the gateway
# mints into responses (well-known JSON, the deprecated
# `<hive>/matrix/*` 301 redirect, future absolute-URL needs):
@ -85,7 +107,7 @@ let
# `forge.sshPort`. See `docs/gateway.md`. Empty attrset when the
# forge isn't behind the gateway.
forgeVhost = lib.optionalAttrs (forgeCfg.behindGateway or false) {
"${forgeCfg.domain}" = vhostTls // {
"${forgeCfg.domain}" = (vhostTlsFor forgeCfg.domain) // {
listen = vhostListen;
extraConfig = securityHeaders;
locations."/" = {
@ -107,7 +129,7 @@ let
# longer-prefix-wins puts `/_matrix/` ahead of `/`. See
# `docs/gateway.md`. Empty attrset when matrix has no gateway host.
matrixVhost = lib.optionalAttrs (matrixCfg.enable && matrixCfg.gatewayHost != null) {
"${matrixCfg.gatewayHost}" = vhostTls // {
"${matrixCfg.gatewayHost}" = (vhostTlsFor matrixCfg.gatewayHost) // {
listen = vhostListen;
extraConfig = securityHeaders;
locations = {