diff --git a/nix/host-modules/hive-gateway/default.nix b/nix/host-modules/hive-gateway/default.nix index fc484b13..ba9d7b37 100644 --- a/nix/host-modules/hive-gateway/default.nix +++ b/nix/host-modules/hive-gateway/default.nix @@ -15,10 +15,6 @@ 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; @@ -267,12 +263,6 @@ 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 @@ -284,9 +274,6 @@ in swaggerUiTheme tlsCert tlsKey - svcCert - svcKey - swarmServiceDomains ; errorPages = import ./error-pages.nix { inherit pkgs; }; }; @@ -375,23 +362,6 @@ 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 8cf647d7..f94395b5 100644 --- a/nix/host-modules/hive-gateway/vhosts.nix +++ b/nix/host-modules/hive-gateway/vhosts.nix @@ -15,9 +15,6 @@ 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 @@ -54,25 +51,6 @@ 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): @@ -107,7 +85,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}" = (vhostTlsFor forgeCfg.domain) // { + "${forgeCfg.domain}" = vhostTls // { listen = vhostListen; extraConfig = securityHeaders; locations."/" = { @@ -129,7 +107,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}" = (vhostTlsFor matrixCfg.gatewayHost) // { + "${matrixCfg.gatewayHost}" = vhostTls // { listen = vhostListen; extraConfig = securityHeaders; locations = { diff --git a/nix/host-modules/hive-tls.nix b/nix/host-modules/hive-tls.nix index b3e3abe4..553e5251 100644 --- a/nix/host-modules/hive-tls.nix +++ b/nix/host-modules/hive-tls.nix @@ -10,10 +10,6 @@ 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 @@ -24,6 +20,13 @@ 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. @@ -94,36 +97,28 @@ 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" - 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")" + 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")" trap 'rm -f "$csr" "$ext" "$only"' EXIT openssl req -newkey rsa:4096 -nodes -sha256 \ -keyout "$leafk" -out "$csr" \ - -subj "/CN=$cn" + -subj "/CN=${domain}" # printf (not a heredoc) so the ext-file lines carry no leading # whitespace once nix has stripped the indented-string indent. { - printf 'subjectAltName=%s\n' "$sans" + 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 'basicConstraints=critical,CA:FALSE\n' printf 'keyUsage=critical,digitalSignature,keyEncipherment\n' printf 'extendedKeyUsage=serverAuth\n' @@ -145,35 +140,6 @@ 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. @@ -352,8 +318,7 @@ 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" - ${signHiveLeaf} - ${signServicesLeaf} + ${signLeafScript} "$d" fi # --- Trust bundle: what a consumer must TRUST, as opposed to @@ -424,39 +389,22 @@ 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 a leaf is within half its validity of expiry. - # The weekly cadence catches this window well before one lapses. + # Re-sign only when the leaf is within half its validity of expiry. + # The weekly cadence catches this window well before the leaf lapses. halflife=$(( ${toString cfg.leafValidityDays} * 86400 / 2 )) - - 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" + 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" exit 0 fi - echo "a leaf is missing or near expiry — re-signing under the current CAs" - before="$(sha256sum "$leaf" "$svcleaf" 2>/dev/null || true)" + echo "gateway leaf missing or near expiry — re-signing under current CA" + before="$(sha256sum "$leaf" 2>/dev/null || true)" - ${signHiveLeaf} - ${signServicesLeaf} + ${signLeafScript} "$d" - after="$(sha256sum "$leaf" "$svcleaf" 2>/dev/null || true)" + after="$(sha256sum "$leaf" 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 31102718..08341269 100644 --- a/nix/host-modules/swarm-ca.nix +++ b/nix/host-modules/swarm-ca.nix @@ -50,13 +50,6 @@ 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 = { @@ -104,23 +97,6 @@ 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; @@ -190,98 +166,5 @@ 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 8798f2d3..41add894 100644 --- a/nix/host-modules/swarm.nix +++ b/nix/host-modules/swarm.nix @@ -23,23 +23,6 @@ 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 { @@ -170,31 +153,9 @@ 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