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.
This commit is contained in:
parent
240ae79ad6
commit
5a83c40dca
3 changed files with 114 additions and 45 deletions
|
|
@ -10,6 +10,10 @@ let
|
||||||
gatewayCfg = config.services.hyperhive.gateway;
|
gatewayCfg = config.services.hyperhive.gateway;
|
||||||
swarmCaCfg = config.services.hyperhive.swarm.ca;
|
swarmCaCfg = config.services.hyperhive.swarm.ca;
|
||||||
domain = hyperhiveCfg.domain;
|
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.
|
# 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
|
# 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.
|
# neither an operator cert (`tls.certDir`) nor ACME is set.
|
||||||
active = hyperhiveCfg.enable && gatewayCfg.useSelfSigned;
|
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 `*.<domain>` 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
|
# 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
|
# is one of exactly two things, chosen by config rather than by what
|
||||||
# happens to be on disk.
|
# happens to be on disk.
|
||||||
|
|
@ -97,28 +94,36 @@ let
|
||||||
-addext "keyUsage=critical,keyCertSign,cRLSign"
|
-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" ''
|
signLeafScript = pkgs.writeShellScript "hive-tls-sign-leaf" ''
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
d="$1"
|
d="$1"
|
||||||
ca="$d/ca.pem"
|
base="$2"
|
||||||
cak="$d/ca-key.pem"
|
cn="$3"
|
||||||
leaf="$d/gateway.pem"
|
sans="$4"
|
||||||
leafk="$d/gateway-key.pem"
|
ca="$5"
|
||||||
csr="$(mktemp "$d/gateway.csr.XXXXXX")"
|
cak="$6"
|
||||||
ext="$(mktemp "$d/leaf.ext.XXXXXX")"
|
leaf="$d/$base.pem"
|
||||||
only="$(mktemp "$d/gateway.leaf.XXXXXX")"
|
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
|
trap 'rm -f "$csr" "$ext" "$only"' EXIT
|
||||||
|
|
||||||
openssl req -newkey rsa:4096 -nodes -sha256 \
|
openssl req -newkey rsa:4096 -nodes -sha256 \
|
||||||
-keyout "$leafk" -out "$csr" \
|
-keyout "$leafk" -out "$csr" \
|
||||||
-subj "/CN=${domain}"
|
-subj "/CN=$cn"
|
||||||
|
|
||||||
# printf (not a heredoc) so the ext-file lines carry no leading
|
# printf (not a heredoc) so the ext-file lines carry no leading
|
||||||
# whitespace once nix has stripped the indented-string indent.
|
# whitespace once nix has stripped the indented-string indent.
|
||||||
{
|
{
|
||||||
printf 'subjectAltName=DNS:%s,DNS:forge.%s,DNS:matrix.%s,DNS:*.%s\n' \
|
printf 'subjectAltName=%s\n' "$sans"
|
||||||
${lib.escapeShellArg domain} ${lib.escapeShellArg domain} \
|
|
||||||
${lib.escapeShellArg domain} ${lib.escapeShellArg domain}
|
|
||||||
printf 'basicConstraints=critical,CA:FALSE\n'
|
printf 'basicConstraints=critical,CA:FALSE\n'
|
||||||
printf 'keyUsage=critical,digitalSignature,keyEncipherment\n'
|
printf 'keyUsage=critical,digitalSignature,keyEncipherment\n'
|
||||||
printf 'extendedKeyUsage=serverAuth\n'
|
printf 'extendedKeyUsage=serverAuth\n'
|
||||||
|
|
@ -140,6 +145,35 @@ let
|
||||||
chmod 0600 "$leafk"
|
chmod 0600 "$leafk"
|
||||||
chmod 0644 "$leaf"
|
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
|
in
|
||||||
{
|
{
|
||||||
# Host-side TLS trust root for the self-signed gateway mode.
|
# Host-side TLS trust root for the self-signed gateway mode.
|
||||||
|
|
@ -318,7 +352,8 @@ in
|
||||||
if [ ! -s "$leaf" ] || [ ! -s "$leafk" ] \
|
if [ ! -s "$leaf" ] || [ ! -s "$leafk" ] \
|
||||||
|| ! openssl x509 -in "$leaf" -noout -checkend 2592000 >/dev/null 2>&1; then
|
|| ! openssl x509 -in "$leaf" -noout -checkend 2592000 >/dev/null 2>&1; then
|
||||||
echo "signing fresh gateway leaf at $leaf"
|
echo "signing fresh gateway leaf at $leaf"
|
||||||
${signLeafScript} "$d"
|
${signHiveLeaf}
|
||||||
|
${signServicesLeaf}
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# --- Trust bundle: what a consumer must TRUST, as opposed to
|
# --- Trust bundle: what a consumer must TRUST, as opposed to
|
||||||
|
|
@ -389,22 +424,39 @@ in
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
d=${lib.escapeShellArg cfg.stateDir}
|
d=${lib.escapeShellArg cfg.stateDir}
|
||||||
leaf="$d/gateway.pem"
|
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.
|
# Re-sign only when a leaf is within half its validity of expiry.
|
||||||
# The weekly cadence catches this window well before the leaf lapses.
|
# The weekly cadence catches this window well before one lapses.
|
||||||
halflife=$(( ${toString cfg.leafValidityDays} * 86400 / 2 ))
|
halflife=$(( ${toString cfg.leafValidityDays} * 86400 / 2 ))
|
||||||
if [ -s "$leaf" ] && \
|
|
||||||
openssl x509 -in "$leaf" -noout -checkend "$halflife" >/dev/null 2>&1; then
|
fresh() { # a leaf is fresh if it exists and is not near expiry
|
||||||
echo "gateway leaf valid for more than half its lifetime — no resign needed"
|
[ -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
|
exit 0
|
||||||
fi
|
fi
|
||||||
|
|
||||||
echo "gateway leaf missing or near expiry — re-signing under current CA"
|
echo "a leaf is missing or near expiry — re-signing under the current CAs"
|
||||||
before="$(sha256sum "$leaf" 2>/dev/null || true)"
|
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
|
if [ "$before" != "$after" ]; then
|
||||||
echo "gateway leaf rotated — propagating into hive-gateway"
|
echo "gateway leaf rotated — propagating into hive-gateway"
|
||||||
systemctl -M hive-gateway restart hive-gateway-self-signed-cert.service || true
|
systemctl -M hive-gateway restart hive-gateway-self-signed-cert.service || true
|
||||||
|
|
|
||||||
|
|
@ -51,23 +51,12 @@ let
|
||||||
else
|
else
|
||||||
"hyperhive";
|
"hyperhive";
|
||||||
|
|
||||||
# The names the services sub-CA is allowed to issue for, read from the
|
# Derived once in ./swarm.nix, read here and by ./hive-tls.nix: the
|
||||||
# service options rather than spelled out here: a constraint list that
|
# CA that name-constrains these and the leaf that carries them as SANs
|
||||||
# doesn't track its own consumers is a constraint that silently stops
|
# must agree exactly, and two modules each assembling the list is how
|
||||||
# covering one.
|
# they stop agreeing. It is also this unit's *rotation trigger* below,
|
||||||
#
|
# which is why the ordering is stable there rather than here.
|
||||||
# Sorted + deduplicated because this list is also the *rotation
|
serviceDomains = hyperhiveCfg.swarm.serviceDomains;
|
||||||
# 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
|
in
|
||||||
{
|
{
|
||||||
options.services.hyperhive.swarm.ca = {
|
options.services.hyperhive.swarm.ca = {
|
||||||
|
|
|
||||||
|
|
@ -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 = {
|
config = {
|
||||||
services.hyperhive.swarm.peerHives = lib.filterAttrs (name: _: name != cfg.hiveName) swarmCfg.hives;
|
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 = [
|
assertions = [
|
||||||
{
|
{
|
||||||
# Guarded on `hiveName != null` so the required-hiveName
|
# Guarded on `hiveName != null` so the required-hiveName
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue