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 = { 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 08341269..31102718 100644 --- a/nix/host-modules/swarm-ca.nix +++ b/nix/host-modules/swarm-ca.nix @@ -50,6 +50,13 @@ let hyperhiveCfg.domain else "hyperhive"; + + # 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 = { @@ -97,6 +104,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 +190,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" + ''; + }; }; } diff --git a/nix/host-modules/swarm.nix b/nix/host-modules/swarm.nix index 41add894..8798f2d3 100644 --- a/nix/host-modules/swarm.nix +++ b/nix/host-modules/swarm.nix @@ -23,6 +23,23 @@ let cfg = config.services.hyperhive; swarmCfg = cfg.swarm; + + # 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 { options.services.hyperhive.swarm.hives = lib.mkOption { @@ -153,9 +170,31 @@ 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 != "") serviceDomains') + ); + assertions = [ { # Guarded on `hiveName != null` so the required-hiveName