From 240ae79ad6fd51faf85bb96dadfaec3b7c2c8c16 Mon Sep 17 00:00:00 2001 From: atlas Date: Wed, 5 Aug 2026 21:04:42 +0200 Subject: [PATCH 1/5] feat(nix): issue a swarm-services sub-CA under the swarm root The swarm's own service names cannot be signed by any hive CA: each hive CA is name-constrained to its hive's domain, and the service names are siblings of that domain, not children. Adding them to a leaf's SAN list only trades a name-mismatch error for a constraint-violation one. So the root issues one more intermediate, constrained to exactly the configured service names -- not to the whole swarm domain. A leaked services CA mints forge./chat./auth. and nothing else. Rotation is why this is separate rather than a second leaf off the root: the constraint enumerates the names, so adding a service re-issues this CA and never touches the root or any hive CA. The name set is written beside the cert and compared on each start, which is what makes re-issuance happen exactly when the names change -- not every boot, and not never. The list is sorted and deduplicated for the same reason: an unstable order would churn a CA that things are meant to pin. Validity is deliberately much shorter than the root's. This CA is meant to be re-issued, so a long window buys nothing and a short one keeps the rotation path exercised rather than theoretical. Carries the root's half-provisioned guard: a key beside a cert that did not sign it looks like it works and issues nothing anyone trusts. --- nix/host-modules/swarm-ca.nix | 128 ++++++++++++++++++++++++++++++++++ 1 file changed, 128 insertions(+) diff --git a/nix/host-modules/swarm-ca.nix b/nix/host-modules/swarm-ca.nix index 08341269..ef623f48 100644 --- a/nix/host-modules/swarm-ca.nix +++ b/nix/host-modules/swarm-ca.nix @@ -50,6 +50,24 @@ let hyperhiveCfg.domain else "hyperhive"; + + # The names the services sub-CA is allowed to issue for, read from the + # service options rather than spelled out here: a constraint list that + # doesn't track its own consumers is a constraint that silently stops + # covering one. + # + # Sorted + deduplicated because this list is also the *rotation + # trigger* below — an unstable order would re-issue the CA on every + # rebuild, and a CA that churns is one nothing can pin. + serviceDomains = lib.sort (a: b: a < b) ( + lib.unique ( + lib.filter (d: d != null && d != "") [ + hyperhiveCfg.swarm.forge.domain + hyperhiveCfg.swarm.matrix.gatewayHost + hyperhiveCfg.swarm.authelia.domain + ] + ) + ); in { options.services.hyperhive.swarm.ca = { @@ -97,6 +115,23 @@ in ''; }; + servicesValidityDays = lib.mkOption { + type = lib.types.int; + default = 1825; + description = '' + Validity window of the swarm-services sub-CA in days (~5y). + Deliberately far shorter than the root's: this CA is *meant* to + be re-issued — adding a swarm service changes its name + constraints and rotates it — so a long window buys nothing, and + a short one keeps the rotation path exercised rather than + theoretical. + + Rotating it is cheap in the way rotating the root is not: it + touches only the swarm-service vhosts, and no peer hive holds it + as an anchor. + ''; + }; + validityDays = lib.mkOption { type = lib.types.int; default = 10950; @@ -166,5 +201,98 @@ in chmod 0644 "$root" ''; }; + + # The swarm-services sub-CA: issues leaves for the swarm's own + # service names, which no hive CA can sign — each of those is + # name-constrained to its own hive's domain, and the service names + # are siblings of it, not children. + # + # Rotation is the point of it being separate (mara: "swarm services + # sub ca that can rotate independently of swarm root ca"): the + # constraint enumerates the exact service names, so adding a service + # re-issues *this* and never touches the root or any hive CA. + systemd.services.swarm-services-ca = { + description = "Issue the swarm-services sub-CA under the swarm root"; + wantedBy = [ "multi-user.target" ]; + after = [ "swarm-ca.service" ]; + requires = [ "swarm-ca.service" ]; + path = [ pkgs.openssl ]; + serviceConfig = { + Type = "oneshot"; + RemainAfterExit = true; + UMask = "0077"; + SyslogIdentifier = "swarm-services-ca"; + }; + script = '' + set -euo pipefail + d=${lib.escapeShellArg cfg.stateDir} + root="$d/root.pem" + rootk="$d/root-key.pem" + ca="$d/services-ca.pem" + cak="$d/services-ca-key.pem" + # The name set this CA was last issued for. Comparing against it + # is what makes re-issuance happen exactly when the service + # names change — not every boot, and not never. + names="$d/services-ca.names" + want=${lib.escapeShellArg (lib.concatStringsSep "\n" serviceDomains)} + + if [ -z "$want" ]; then + echo "no swarm service domains configured — nothing to issue for" + exit 0 + fi + + # Same half-provisioned guard as the root: a key beside a cert + # that did not sign it looks like it works and issues nothing + # anyone will trust. + if { [ -e "$ca" ] && [ ! -e "$cak" ]; } || { [ -e "$cak" ] && [ ! -e "$ca" ]; }; then + echo "services sub-CA half-provisioned ($ca / $cak) — refusing to generate over it" >&2 + exit 1 + fi + + if [ -s "$ca" ] && [ -s "$cak" ] && [ -f "$names" ] \ + && [ "$(cat "$names")" = "$want" ]; then + echo "services sub-CA present and covers the configured names — leaving it alone" + exit 0 + fi + + if [ ! -s "$root" ] || [ ! -s "$rootk" ]; then + echo "no swarm root CA at $root — cannot issue the services sub-CA under it" >&2 + exit 1 + fi + + echo "issuing services sub-CA at $ca for: $(echo "$want" | tr '\n' ' ')" + csr="$(mktemp "$d/services-ca.csr.XXXXXX")" + ext="$(mktemp "$d/services-ca.ext.XXXXXX")" + trap 'rm -f "$csr" "$ext"' EXIT + + openssl req -newkey rsa:4096 -nodes -sha256 \ + -keyout "$cak" -out "$csr" \ + -subj "/CN=swarm-services-ca ${swarmLabel}" + + { + # pathlen:0 — this signs leaves and delegates no further. + printf 'basicConstraints=critical,CA:TRUE,pathlen:0\n' + printf 'keyUsage=critical,keyCertSign,cRLSign\n' + printf 'subjectKeyIdentifier=hash\n' + printf 'authorityKeyIdentifier=keyid:always\n' + # Constrained to the exact service names, not to the whole + # swarm domain: a leaked services CA should mint `forge.`, + # `chat.`, `auth.` and nothing else. The IP exclusions are not + # redundant — a DNS constraint says nothing about an + # iPAddress SAN, and an unconstrained name type is a name + # type this CA is unconstrained for. + printf 'nameConstraints=critical,permitted;%s,excluded;IP:0.0.0.0/0.0.0.0,excluded;IP:0:0:0:0:0:0:0:0/0:0:0:0:0:0:0:0\n' \ + "$(echo "$want" | sed 's/^/DNS:/' | paste -sd, -)" + } > "$ext" + + openssl x509 -req -in "$csr" -CA "$root" -CAkey "$rootk" \ + -CAcreateserial -days ${toString cfg.servicesValidityDays} -sha256 \ + -extfile "$ext" -out "$ca" + + printf '%s' "$want" > "$names" + chmod 0600 "$cak" + chmod 0644 "$ca" "$names" + ''; + }; }; } From 5a83c40dca7a9648cb585d32475c77af854f1d59 Mon Sep 17 00:00:00 2001 From: atlas Date: Wed, 5 Aug 2026 21:16:25 +0200 Subject: [PATCH 2/5] feat(nix): issue a swarm-services leaf, and renew it with the hive one The swarm's service names cannot go on the hive leaf: the hive CA is name-constrained to the hive domain and those names are siblings of it. So there is a second leaf, signed by the services sub-CA. signLeafScript is parameterised rather than duplicated -- same ceremony, different issuer and names -- so the two cannot drift in how they are built. The name list itself is derived once, as a read-only swarm.serviceDomains, and read by both the sub-CA that name-constrains those names and the leaf that carries them as SANs: two modules each assembling the list is how they stop agreeing. The renewal unit is the point of this commit as much as the leaf. hive-tls-resign now knows about both, because a leaf that first-boot issuance creates and weekly renewal ignores looks perfect for its entire validity and then expires with no warning -- the failure is invisible until it is total. The freshness test became a function over a leaf rather than a check of one, so adding a third leaf is a line rather than a rewrite. The services leaf is skipped where the sub-CA is absent: it exists only where the swarm CA is autoconfigured, and on a hive whose certs come from its operator the correct state is no leaf, not a stale one. Also drops a comment that documented signLeafScript's old signature from above an unrelated binding. --- nix/host-modules/hive-tls.nix | 108 +++++++++++++++++++++++++--------- nix/host-modules/swarm-ca.nix | 23 ++------ nix/host-modules/swarm.nix | 28 +++++++++ 3 files changed, 114 insertions(+), 45 deletions(-) diff --git a/nix/host-modules/hive-tls.nix b/nix/host-modules/hive-tls.nix index 553e5251..b3e3abe4 100644 --- a/nix/host-modules/hive-tls.nix +++ b/nix/host-modules/hive-tls.nix @@ -10,6 +10,10 @@ let gatewayCfg = config.services.hyperhive.gateway; swarmCaCfg = config.services.hyperhive.swarm.ca; domain = hyperhiveCfg.domain; + # Derived once in ./swarm.nix and read here + in ./swarm-ca.nix, so + # the names this leaf carries as SANs and the names the sub-CA is + # constrained to cannot disagree. + swarmServiceDomains = hyperhiveCfg.swarm.serviceDomains; # The host-managed hive CA is the trust anchor for self-signed mode. # It is only stood up when the gateway actually serves a self-signed @@ -20,13 +24,6 @@ let # neither an operator cert (`tls.certDir`) nor ACME is set. active = hyperhiveCfg.enable && gatewayCfg.useSelfSigned; - # The leaf-signing action shared by the boot-time `hive-tls-ca` - # generation and the weekly `hive-tls-resign` renewal: fresh key + - # CSR, SAN ext-file, sign under the (stable) CA, tighten modes. - # Takes the TLS state dir as `$1`; each caller keeps its own - # when-to-sign condition. The leaf covers the bare hive domain plus - # `forge.`, `matrix.` and `*.` so all sub-domains validate - # under the same cert + the hive CA. # How this hive's CA comes into existence when it is missing — and it # is one of exactly two things, chosen by config rather than by what # happens to be on disk. @@ -97,28 +94,36 @@ let -addext "keyUsage=critical,keyCertSign,cRLSign" ''; + # Sign one leaf. Parameterised rather than hardcoded to `gateway.*` + # because there are now two: the hive's own leaf, issued by the hive + # CA, and the swarm-services leaf, issued by the services sub-CA that + # ./swarm-ca.nix maintains. Same ceremony, different issuer and names + # — and one script means the two cannot drift in how they are built. + # + # $1 stateDir $2 basename $3 CN $4 SAN list $5 issuer cert $6 issuer key signLeafScript = pkgs.writeShellScript "hive-tls-sign-leaf" '' set -euo pipefail d="$1" - ca="$d/ca.pem" - cak="$d/ca-key.pem" - leaf="$d/gateway.pem" - leafk="$d/gateway-key.pem" - csr="$(mktemp "$d/gateway.csr.XXXXXX")" - ext="$(mktemp "$d/leaf.ext.XXXXXX")" - only="$(mktemp "$d/gateway.leaf.XXXXXX")" + base="$2" + cn="$3" + sans="$4" + ca="$5" + cak="$6" + leaf="$d/$base.pem" + leafk="$d/$base-key.pem" + csr="$(mktemp "$d/$base.csr.XXXXXX")" + ext="$(mktemp "$d/$base.ext.XXXXXX")" + only="$(mktemp "$d/$base.leaf.XXXXXX")" trap 'rm -f "$csr" "$ext" "$only"' EXIT openssl req -newkey rsa:4096 -nodes -sha256 \ -keyout "$leafk" -out "$csr" \ - -subj "/CN=${domain}" + -subj "/CN=$cn" # printf (not a heredoc) so the ext-file lines carry no leading # whitespace once nix has stripped the indented-string indent. { - printf 'subjectAltName=DNS:%s,DNS:forge.%s,DNS:matrix.%s,DNS:*.%s\n' \ - ${lib.escapeShellArg domain} ${lib.escapeShellArg domain} \ - ${lib.escapeShellArg domain} ${lib.escapeShellArg domain} + printf 'subjectAltName=%s\n' "$sans" printf 'basicConstraints=critical,CA:FALSE\n' printf 'keyUsage=critical,digitalSignature,keyEncipherment\n' printf 'extendedKeyUsage=serverAuth\n' @@ -140,6 +145,35 @@ let chmod 0600 "$leafk" chmod 0644 "$leaf" ''; + + # The hive's own leaf: signed by the hive CA, covering the hive domain + # and its sub-domains. + signHiveLeaf = '' + ${signLeafScript} "$d" gateway ${lib.escapeShellArg domain} \ + ${lib.escapeShellArg "DNS:${domain},DNS:forge.${domain},DNS:matrix.${domain},DNS:*.${domain}"} \ + "$d/ca.pem" "$d/ca-key.pem" + ''; + + # The swarm-services leaf: signed by the services sub-CA, covering the + # swarm's service names. Those are *siblings* of the hive domain, not + # children, so the hive CA is name-constrained out of them and cannot + # sign this however its SAN list is written. + # + # Skipped when the sub-CA isn't on disk: it only exists where the + # swarm CA is autoconfigured, and a hive that gets its certs from its + # operator has nothing for this to do. + signServicesLeaf = lib.optionalString (swarmServiceDomains != [ ]) '' + servicesCa=${lib.escapeShellArg "${swarmCaCfg.stateDir}/services-ca.pem"} + servicesCaKey=${lib.escapeShellArg "${swarmCaCfg.stateDir}/services-ca-key.pem"} + if [ -s "$servicesCa" ] && [ -s "$servicesCaKey" ]; then + ${signLeafScript} "$d" swarm-services \ + ${lib.escapeShellArg (builtins.head swarmServiceDomains)} \ + ${lib.escapeShellArg (lib.concatMapStringsSep "," (n: "DNS:${n}") swarmServiceDomains)} \ + "$servicesCa" "$servicesCaKey" + else + echo "no swarm-services sub-CA at $servicesCa — skipping the services leaf" + fi + ''; in { # Host-side TLS trust root for the self-signed gateway mode. @@ -318,7 +352,8 @@ in if [ ! -s "$leaf" ] || [ ! -s "$leafk" ] \ || ! openssl x509 -in "$leaf" -noout -checkend 2592000 >/dev/null 2>&1; then echo "signing fresh gateway leaf at $leaf" - ${signLeafScript} "$d" + ${signHiveLeaf} + ${signServicesLeaf} fi # --- Trust bundle: what a consumer must TRUST, as opposed to @@ -389,22 +424,39 @@ in set -euo pipefail d=${lib.escapeShellArg cfg.stateDir} leaf="$d/gateway.pem" + # ⚠️ EVERY leaf this host issues must be listed here. A leaf that + # first-boot issuance creates and this unit does not know about + # looks perfect for its entire validity and then expires with no + # warning — the failure is invisible until it is total. + svcleaf="$d/swarm-services.pem" - # Re-sign only when the leaf is within half its validity of expiry. - # The weekly cadence catches this window well before the leaf lapses. + # Re-sign only when a leaf is within half its validity of expiry. + # The weekly cadence catches this window well before one lapses. halflife=$(( ${toString cfg.leafValidityDays} * 86400 / 2 )) - if [ -s "$leaf" ] && \ - openssl x509 -in "$leaf" -noout -checkend "$halflife" >/dev/null 2>&1; then - echo "gateway leaf valid for more than half its lifetime — no resign needed" + + fresh() { # a leaf is fresh if it exists and is not near expiry + [ -s "$1" ] && openssl x509 -in "$1" -noout -checkend "$halflife" >/dev/null 2>&1 + } + + # The services leaf is only expected where the sub-CA exists; + # elsewhere its absence is the correct state, not a stale leaf. + want_svc=${if swarmServiceDomains == [ ] then "0" else "1"} + if [ ! -s ${lib.escapeShellArg "${swarmCaCfg.stateDir}/services-ca.pem"} ]; then + want_svc=0 + fi + + if fresh "$leaf" && { [ "$want_svc" = 0 ] || fresh "$svcleaf"; }; then + echo "leaves valid for more than half their lifetime — no resign needed" exit 0 fi - echo "gateway leaf missing or near expiry — re-signing under current CA" - before="$(sha256sum "$leaf" 2>/dev/null || true)" + echo "a leaf is missing or near expiry — re-signing under the current CAs" + before="$(sha256sum "$leaf" "$svcleaf" 2>/dev/null || true)" - ${signLeafScript} "$d" + ${signHiveLeaf} + ${signServicesLeaf} - after="$(sha256sum "$leaf" 2>/dev/null || true)" + after="$(sha256sum "$leaf" "$svcleaf" 2>/dev/null || true)" if [ "$before" != "$after" ]; then echo "gateway leaf rotated — propagating into hive-gateway" systemctl -M hive-gateway restart hive-gateway-self-signed-cert.service || true diff --git a/nix/host-modules/swarm-ca.nix b/nix/host-modules/swarm-ca.nix index ef623f48..31102718 100644 --- a/nix/host-modules/swarm-ca.nix +++ b/nix/host-modules/swarm-ca.nix @@ -51,23 +51,12 @@ let else "hyperhive"; - # The names the services sub-CA is allowed to issue for, read from the - # service options rather than spelled out here: a constraint list that - # doesn't track its own consumers is a constraint that silently stops - # covering one. - # - # Sorted + deduplicated because this list is also the *rotation - # trigger* below — an unstable order would re-issue the CA on every - # rebuild, and a CA that churns is one nothing can pin. - serviceDomains = lib.sort (a: b: a < b) ( - lib.unique ( - lib.filter (d: d != null && d != "") [ - hyperhiveCfg.swarm.forge.domain - hyperhiveCfg.swarm.matrix.gatewayHost - hyperhiveCfg.swarm.authelia.domain - ] - ) - ); + # Derived once in ./swarm.nix, read here and by ./hive-tls.nix: the + # CA that name-constrains these and the leaf that carries them as SANs + # must agree exactly, and two modules each assembling the list is how + # they stop agreeing. It is also this unit's *rotation trigger* below, + # which is why the ordering is stable there rather than here. + serviceDomains = hyperhiveCfg.swarm.serviceDomains; in { options.services.hyperhive.swarm.ca = { diff --git a/nix/host-modules/swarm.nix b/nix/host-modules/swarm.nix index 41add894..ab4d706a 100644 --- a/nix/host-modules/swarm.nix +++ b/nix/host-modules/swarm.nix @@ -153,9 +153,37 @@ in ''; }; + options.services.hyperhive.swarm.serviceDomains = lib.mkOption { + type = lib.types.listOf lib.types.str; + readOnly = true; + internal = true; + description = '' + Read-only: the public hostnames of the swarm's own services, in a + stable sorted order. Second derived set alongside `peerHives`, and + here for the same reason — the CA that name-constrains these and + the leaf that carries them as SANs must agree exactly, and two + modules each assembling the list is how they stop agreeing. + + Sorted and deduplicated deliberately: consumers compare this list + against what they issued last time to decide whether to re-issue, + so an unstable order would churn a certificate that other things + are meant to pin. + ''; + }; + config = { 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 + ] + ) + ); + assertions = [ { # Guarded on `hiveName != null` so the required-hiveName From 11b8140981315c427a30d893525e354b0a571b2d Mon Sep 17 00:00:00 2001 From: atlas Date: Wed, 5 Aug 2026 21:23:38 +0200 Subject: [PATCH 3/5] 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. --- nix/host-modules/hive-gateway/default.nix | 30 +++++++++++++++++++++++ nix/host-modules/hive-gateway/vhosts.nix | 26 ++++++++++++++++++-- 2 files changed, 54 insertions(+), 2 deletions(-) diff --git a/nix/host-modules/hive-gateway/default.nix b/nix/host-modules/hive-gateway/default.nix index ba9d7b37..fc484b13 100644 --- a/nix/host-modules/hive-gateway/default.nix +++ b/nix/host-modules/hive-gateway/default.nix @@ -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 ''; }; diff --git a/nix/host-modules/hive-gateway/vhosts.nix b/nix/host-modules/hive-gateway/vhosts.nix index f94395b5..8cf647d7 100644 --- a/nix/host-modules/hive-gateway/vhosts.nix +++ b/nix/host-modules/hive-gateway/vhosts.nix @@ -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 # `/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 = { From 93b89fd56614a727bff2c21eec369c3e930763ec Mon Sep 17 00:00:00 2001 From: atlas Date: Wed, 5 Aug 2026 21:27:32 +0200 Subject: [PATCH 4/5] 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. --- nix/host-modules/swarm.nix | 65 ++++++++++++++++++++++++++++++++++---- 1 file changed, 58 insertions(+), 7 deletions(-) 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 — From 30551464c15870ce4b92fd9255864a0d05408d0c Mon Sep 17 00:00:00 2001 From: atlas Date: Wed, 5 Aug 2026 22:22:09 +0200 Subject: [PATCH 5/5] fix(nix): drop the swarm-service-domain assertion, it asserted intent Measured against a real config, not reasoned about: pinning `swarm.forge.domain = "forge."` while `swarm.domain` is a different apex -- the documented way an existing deployment keeps its names while joining a swarm -- FAILED eval under this assertion. And the reason it gave was false. It said those names are certified by the services sub-CA, "which is constrained to the swarm's own tree, so a name outside it has no issuer here". The sub-CA is constrained to the **configured names** (swarm-ca.nix builds its nameConstraints from the derived list), and the swarm root carries no name constraints at all -- only basicConstraints and keyUsage. So any configured name is issuable, and a legacy name under the hive domain is additionally signable by the hive CA. The assertion therefore encoded the shape I had in mind rather than a constraint the code implements, and it rejected a configuration that works. Removed rather than weakened: a narrower version would still carry a claim nothing backs. `serviceDomains` stays -- it feeds the sub-CA's name constraints, the leaf's SANs and the per-vhost cert choice, and those three agreeing is the thing that mattered. The (option, value) pairs collapse back to a plain list, since the provenance existed only for the assertion message. --- nix/host-modules/swarm.nix | 72 +++++++++----------------------------- 1 file changed, 16 insertions(+), 56 deletions(-) diff --git a/nix/host-modules/swarm.nix b/nix/host-modules/swarm.nix index 2afbeb2d..8798f2d3 100644 --- a/nix/host-modules/swarm.nix +++ b/nix/host-modules/swarm.nix @@ -24,23 +24,21 @@ 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; - } + # Public hostnames of the swarm's own services, in declaration order. + # `serviceDomains` below is this set sorted + deduplicated. + # + # ⚠️ These are NOT required to be under `swarm.domain`. An earlier + # revision asserted that, reasoning that the services sub-CA is + # constrained to the swarm's tree — but the sub-CA is constrained to + # the **configured names** (./swarm-ca.nix) and the swarm root carries + # no name constraints at all, so any configured name is issuable. The + # assertion encoded an intended shape, not a property of the code, and + # it rejected the supported migration path: a hive pinning its old + # `forge.` while joining a swarm at a different apex. + serviceDomains' = [ + swarmCfg.forge.domain + swarmCfg.matrix.gatewayHost + swarmCfg.authelia.domain ]; in { @@ -194,48 +192,10 @@ 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 != "") (lib.map (s: s.value) namedServiceDomains)) + lib.unique (lib.filter (d: d != null && d != "") serviceDomains') ); 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 —