Watch
0
0
Fork
You've already forked hyperhive
0

hive-tls: issue the gateway leaf from bao with a per-hive identity

Every hive gets its own store identity (CN
<deploy.bao.hiveLeafCommonNamePrefix>-<hive>) and its own role on the
existing pki mount (<deploy.bao.hiveLeafPkiRolePrefix>-<hive>), whose
allowed_domains is the hive's domain and *.<domain>. The policy grants
update on that one issue path. swarm-bao-hive-leaf-policy writes role,
policy and cert-auth role for every hive in swarm.hives; the store's host
mints its own hive's identity in glue-bao-tls.nix and a new glue file
points deploy.hive-controller.tls.baoHiveIdentity{Cert,Key}File at it.
Hives on other hosts place it by hand. swarm.nix reserves the composed CN.

hive-tls-ca no longer generates a hive CA or signs a leaf: caGenScript,
signLeafScript/signHiveLeaf and the adoption block are gone, ca-key.pem
is deleted where it exists, and the unit only assembles trust-bundle.pem.
The retired ca.pem/ca-previous.pem stay in the bundle so leaves they
signed keep verifying.

hive-tls-resign becomes the bao issuance: at boot, on deploy and from a
daily timer it re-issues when the leaf is missing, past half the role's
lifetime, missing a name, or not chained to the store's pki root. Nothing
orders the gateway after it, so a valid leaf on disk never waits on the
store; a failed renewal leaves the leaf in place. A missing identity
fails the unit naming both options, as does the root fetch on a hive
fronting no swarm name, which now presents this identity instead of the
reader cert.

caValidityDays and leafValidityDays are removed (both spellings), since
nothing reads them.

Refs #4588
This commit is contained in:
atlas 2026-10-04 15:11:48 +02:00 • committed by mara
commit 5526daab97
16 changed files with 859 additions and 628 deletions

View file

@ -32,6 +32,7 @@
./glue-controller-bao-identity.nix
./glue-forge-oidc-client.nix
./glue-grafana-oidc-client.nix
./glue-hive-tls-bao-identity.nix
./glue-matrix-bao-token.nix
./glue-matrix-ctl-bao-identity.nix
./glue-nats-auth-bao-identity.nix

View file

@ -325,23 +325,45 @@ in
[ "services" "hyperhive" "deploy" "singleHostSwarm" ]
)
# The hive CA's own knobs. They sat at the TOP of `services.hyperhive`,
# which is meant to be everything about hyperhive rather than the settings
# of one hive — and where the CA lives, how long it lasts and how long its
# leaves last are decisions of the host that holds the key. `hive-controller`
# is hive-c0re's new name (mara, on the issue), so the daemon that owns the
# CA is what they hang off.
# The hive TLS state dir sat at the TOP of `services.hyperhive`, which is
# meant to be everything about hyperhive rather than the settings of one
# hive. `hive-controller` is hive-c0re's new name (mara, on the issue).
(lib.mkRenamedOptionModule
[ "services" "hyperhive" "tls" "stateDir" ]
[ "services" "hyperhive" "deploy" "hive-controller" "tls" "stateDir" ]
)
(lib.mkRenamedOptionModule
[ "services" "hyperhive" "tls" "caValidityDays" ]
# The hive CA's lifetime and its leaves', both spellings, removed rather
# than renamed: no hive CA is generated, and the store's per-hive role
# sets the gateway leaf's lifetime.
(lib.mkRemovedOptionModule [ "services" "hyperhive" "tls" "caValidityDays" ] ''
No hive CA is generated any more: the gateway leaf is issued by the
secret store's pki mount, whose per-hive role sets its lifetime
(services.hyperhive.deploy.bao.servicesPkiLeafTtlHours). Remove this
definition. See docs/swarm/ca.md.
'')
(lib.mkRemovedOptionModule [ "services" "hyperhive" "tls" "leafValidityDays" ] ''
No hive CA is generated any more: the gateway leaf is issued by the
secret store's pki mount, whose per-hive role sets its lifetime
(services.hyperhive.deploy.bao.servicesPkiLeafTtlHours). Remove this
definition. See docs/swarm/ca.md.
'')
(lib.mkRemovedOptionModule
[ "services" "hyperhive" "deploy" "hive-controller" "tls" "caValidityDays" ]
''
No hive CA is generated any more: the gateway leaf is issued by the
secret store's pki mount, whose per-hive role sets its lifetime
(services.hyperhive.deploy.bao.servicesPkiLeafTtlHours). Remove this
definition. See docs/swarm/ca.md.
''
)
(lib.mkRenamedOptionModule
[ "services" "hyperhive" "tls" "leafValidityDays" ]
(lib.mkRemovedOptionModule
[ "services" "hyperhive" "deploy" "hive-controller" "tls" "leafValidityDays" ]
''
No hive CA is generated any more: the gateway leaf is issued by the
secret store's pki mount, whose per-hive role sets its lifetime
(services.hyperhive.deploy.bao.servicesPkiLeafTtlHours). Remove this
definition. See docs/swarm/ca.md.
''
)
# The WireGuard mesh, whole. Unlike every rename above this one moves a

View file

@ -261,6 +261,14 @@ in
# is a swarm singleton, so elsewhere this is the file an operator copies.
[ -s ${pkiDir}/nats-auth.pem ] || ${signLeaf} ${pkiDir} nats-auth \
${lib.escapeShellArg deployCfg.bao.natsAuthCommonName} "" clientAuth
# This hive's gateway-leaf identity, which opens
# `pki/issue/<hiveLeafPkiRolePrefix>-<hive>` and nothing else. Its
# subject carries this hive's name, like the matrix-token and
# queue-agent leaves above, because ./swarm-bao.nix writes one role per
# hive. Hives on other hosts are issued theirs out of band.
[ -s ${pkiDir}/hive-leaf.pem ] || ${signLeaf} ${pkiDir} hive-leaf \
${lib.escapeShellArg "${deployCfg.bao.hiveLeafCommonNamePrefix}-${clientCn}"} "" clientAuth
'';
};
};

View file

@ -0,0 +1,34 @@
# Glue: point the gateway-leaf issuance at the hive's own bao identity minted
# for it.
#
# ONE PAIRING PER FILE — the hive's gateway-leaf principal ← bao, and nothing
# else. Deleting this leaves a hive that takes an operator-provided path to
# that credential, which is what every hive on a host without the store does.
#
# ⚠️ The minting is NOT here. ./glue-bao-tls.nix holds the CA and signs the
# leaf, for the reason ./glue-services-issuer-bao-identity.nix gives.
#
# Gated on the minted PKI existing, as that file is.
#
# Everything is `mkDefault`. An operator naming their own paths wins.
{
lib,
config,
...
}:
let
baoDeploy = config.services.hyperhive.deploy.bao;
# Where ./glue-bao-tls.nix puts the leaves, derived from the reader's own
# path as ./glue-services-issuer-bao-identity.nix derives it.
haveMintedPki = baoDeploy.clientCertFile != null;
pkiDir = if haveMintedPki then builtins.dirOf baoDeploy.clientCertFile else null;
in
{
config = lib.mkIf haveMintedPki {
services.hyperhive.deploy.hive-controller.tls = {
baoHiveIdentityCertFile = lib.mkDefault "${pkiDir}/hive-leaf.pem";
baoHiveIdentityKeyFile = lib.mkDefault "${pkiDir}/hive-leaf-key.pem";
};
};
}

View file

@ -286,7 +286,7 @@ in
chmod 0755 ${tlsDir}
# Copy the leaf in. `install` writes atomically with the
# target mode; runs as root so the 0600 root:root key written
# by hive-tls-ca is readable. Key ends up root:nginx 0640 so
# by hive-tls-resign is readable. Key ends up root:nginx 0640 so
# nginx-pre-start
# (which runs `nginx -t` as the nginx user, not root) can
# read it — a 0600 root:root key passes the master load but

View file

@ -49,167 +49,68 @@ let
gatewayWaitsForServicesLeaf =
localServiceDomains != [ ] && localServiceDomains != [ baoCfg.ui.domain ];
# 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
# cert: the gateway must run here and be in self-signed mode. The
# self-signed condition is the gateway module's single source of truth
# The gateway's own leaf is only issued when the gateway actually serves a
# self-signed cert: the gateway must run here and be in self-signed mode.
# The self-signed condition is the gateway module's single source of truth
# (`gateway.useSelfSigned`): true when neither an operator cert
# (`tls.certDir`) nor ACME is set.
active = gatewayCfg.enable && gatewayCfg.useSelfSigned;
# 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.
#
# Issuing a sub-CA requires the swarm root's PRIVATE key, so it can
# only happen where that key legitimately lives: the single-host
# deployment that `swarm.ca.autoConfigure` describes. A host cannot
# infer that it is that host — a swarm's services and its hives can
# sit anywhere — so with the flag off this hive self-signs exactly as
# it always has, and an operator who wants it in the hierarchy
# installs the CA themselves. Falling back to self-signed rather than
# failing keeps a plain hive working out of the box; what it loses is
# membership of a swarm's trust, which is the correct thing to lose
# for a hive nobody has federated.
caGenScript =
if swarmCaCfg.autoConfigure then
# The role this hive's gateway leaf is issued through. ./swarm-bao.nix
# writes one per hive in the swarm directory, under the same prefix, and
# narrows it to that hive's domain and `*.` that domain. So the leaf carries
# those two names and no others: a configured service name goes on the
# swarm-services leaf below, and asking this role for one is a refusal.
hiveLeafRole =
if hyperhiveCfg.hiveName == null then
null
else
"${baoDeploy.hiveLeafPkiRolePrefix}-${hyperhiveCfg.hiveName}";
# The hive's own store identity: its subject is
# `<hiveLeafCommonNamePrefix>-<hiveName>`, and its policy may issue from
# `hiveLeafRole` alone. It also carries the root fetch's handshake.
haveHiveIdentity = cfg.baoHiveIdentityCertFile != null && cfg.baoHiveIdentityKeyFile != null;
showPath = p: if p == null then "unset" else p;
hiveIdentityOptions = "services.hyperhive.deploy.hive-controller.tls.baoHiveIdentityCertFile (${showPath cfg.baoHiveIdentityCertFile}) and services.hyperhive.deploy.hive-controller.tls.baoHiveIdentityKeyFile (${showPath cfg.baoHiveIdentityKeyFile})";
# Fails the unit, naming both options, when the identity is unset or a file
# is unreadable. Every hive must hold one.
requireHiveIdentity =
purpose:
if haveHiveIdentity then
''
# The root should exist — `swarm-ca.service` runs before this and
# is required by it. If it doesn't, something upstream failed and
# signing with a half-provisioned root would be worse than stopping.
if [ ! -s "$root" ] || [ ! -s "$rootk" ]; then
echo "swarm.ca.autoConfigure is set but there is no root CA key at $rootk" >&2
echo "(swarm-ca.service should have generated it) — refusing to issue a hive CA." >&2
missing=""
for f in ${
lib.escapeShellArgs [
cfg.baoHiveIdentityCertFile
cfg.baoHiveIdentityKeyFile
]
}; do
[ -r "$f" ] || missing="$missing $f"
done
if [ -n "$missing" ]; then
echo "this hive has no usable store identity, so it cannot ${purpose}: cannot read$missing." >&2
echo "place the files named by ${hiveIdentityOptions}." >&2
exit 1
fi
echo "issuing fresh hive CA at $ca under the swarm root"
cacsr="$(mktemp "$d/ca.csr.XXXXXX")"
caext="$(mktemp "$d/ca.ext.XXXXXX")"
trap 'rm -f "$cacsr" "$caext"' EXIT
openssl req -newkey rsa:4096 -nodes -sha256 \
-keyout "$cak" -out "$cacsr" \
-subj "/CN=hive-ca ${domain}"
# printf (not a heredoc) so the ext-file lines carry no leading
# whitespace once nix has stripped the indented-string indent.
{
printf 'basicConstraints=critical,CA:TRUE,pathlen:0\n'
printf 'keyUsage=critical,keyCertSign,cRLSign\n'
printf 'subjectKeyIdentifier=hash\n'
printf 'authorityKeyIdentifier=keyid:always\n'
# The constraint is the point of the hierarchy, not a
# flourish: without it a leaked hive CA mints any name in
# the swarm, and it is verifiers that enforce this, not our
# good behaviour. The IP exclusions are not redundant — a
# DNS constraint says nothing about an iPAddress SAN, and a
# name type nobody constrained is a name type this CA is
# unconstrained for.
printf 'nameConstraints=critical,permitted;DNS:%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' \
${lib.escapeShellArg domain}
} > "$caext"
openssl x509 -req -in "$cacsr" -CA "$root" -CAkey "$rootk" \
-CAcreateserial -days ${toString cfg.caValidityDays} -sha256 \
-extfile "$caext" -out "$ca"
rm -f "$cacsr" "$caext"
trap - EXIT
''
else
''
echo "generating fresh self-signed hive CA at $ca"
openssl req -x509 -newkey rsa:4096 -nodes -sha256 \
-days ${toString cfg.caValidityDays} \
-keyout "$cak" -out "$ca" \
-subj "/CN=hive-ca ${domain}" \
-addext "basicConstraints=critical,CA:TRUE,pathlen:0" \
-addext "keyUsage=critical,keyCertSign,cRLSign"
echo "this hive has no store identity, so it cannot ${purpose}." >&2
echo "set ${hiveIdentityOptions} and place both files." >&2
exit 1
'';
# 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")"
trap 'rm -f "$csr" "$ext" "$only"' EXIT
openssl req -newkey rsa:4096 -nodes -sha256 \
-keyout "$leafk" -out "$csr" \
-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=%s\n' "$sans"
printf 'basicConstraints=critical,CA:FALSE\n'
printf 'keyUsage=critical,digitalSignature,keyEncipherment\n'
printf 'extendedKeyUsage=serverAuth\n'
} > "$ext"
openssl x509 -req -in "$csr" -CA "$ca" -CAkey "$cak" \
-CAcreateserial -days ${toString cfg.leafValidityDays} -sha256 \
-extfile "$ext" -out "$only"
# nginx serves this file verbatim, so it must carry the leaf AND its
# issuer: the hive CA is an intermediate under the swarm root, and a
# client that anchors on the root cannot build the middle of the
# chain by itself. Agents anchor on the hive CA directly and
# validated either way — the appended cert is what makes a swarm
# peer, or anything else holding only the root, work.
# Leaf first: both `ssl_certificate` and the `openssl x509 -in`
# expiry checks read the first cert in the file.
cat "$only" "$ca" > "$leaf"
chmod 0600 "$leafk"
chmod 0644 "$leaf"
'';
# The hive's own leaf: signed by the hive CA, covering the hive domain
# and its sub-domains.
#
# `forge.` and `matrix.` were listed here explicitly and both were
# redundant with the wildcard beside them — a wildcard covers exactly
# one label, and those are one label. Naming them read as policy
# ("these two services live under this leaf"), which is the reason they
# outlived it: a configured service name is no longer necessarily under
# this domain, and when it isn't, this is the one list it cannot join.
# The hive CA is `nameConstraints=permitted;DNS:<hive domain>`, and a
# violation invalidates the CERTIFICATE rather than the offending name
# — measured with openssl, not reasoned about — so one sibling name
# added here takes the leaf down for the dashboard and every other
# vhost sharing it. Configured service names get the swarm-services
# leaf below, whose sub-CA is constrained to exactly those names.
signHiveLeaf = ''
${signLeafScript} "$d" gateway ${lib.escapeShellArg domain} \
${lib.escapeShellArg "DNS:${domain},DNS:*.${domain}"} \
"$d/ca.pem" "$d/ca-key.pem"
'';
# Shared by every unit that decides whether to re-issue a leaf. It
# lives here rather than in one of them because the guards have to
# agree: they answer the same question at different times
# (`hive-tls-ca` at service activation, i.e. on the deploy;
# `hive-tls-resign` from a weekly timer for a host that stays up long
# enough to drift; `swarm-services-cert` for the leaf the store
# issues). A rule implemented in one and not the others is worse than
# one implemented in none — it looks fixed and only fires on whichever
# path you did not take, which is exactly how a corrected
# `serviceDomains` still served a stale leaf.
# agree: they answer the same question for different leaves
# (`hive-tls-resign` for the hive leaf, at boot, on the deploy and from
# its timer; `swarm-services-cert` for the services leaf). A rule
# implemented in one and not the others is worse than one implemented
# in none — it looks fixed and only fires on whichever path you did not
# take, which is exactly how a corrected `serviceDomains` still served a
# stale leaf.
#
# Expects `$d` (state dir) to be set; defines `$leaf`-adjacent names and
# `covers`.
@ -254,18 +155,13 @@ let
[ "$_missing" = 0 ]
}
# True when the leaf this host SIGNS is present and carries its
# configured names. Expiry is the callers' own business — they use
# different windows.
# True when the hive leaf is present and carries its configured
# names. Expiry is the callers' own business — they use different
# windows.
#
# ⚠️ The services leaf is deliberately NOT part of this any more, and
# the omission is load-bearing rather than a simplification. Its two
# callers sign under the hive CA's key; the services leaf comes out
# of the secret store, which neither of them can reach. A guard that
# went on reporting it stale would make `hive-tls-ca` re-sign the
# hive leaf forever over a file it has no way to fix. The
# `swarm-services-cert` unit below holds the same rule for that leaf,
# using the `covers` helper above so the two cannot drift.
# The services leaf is not part of this: `swarm-services-cert` holds
# the same rule for it, using the `covers` helper above so the two
# cannot drift.
leavesCoverNames() {
covers "$leaf" "$hiveNames"
}
@ -306,9 +202,9 @@ let
// lib.optionalAttrs (baoDeploy.serverCaFile != null) {
BAO_CACERT = baoDeploy.serverCaFile;
};
haveReaderIdentity = baoDeploy.clientCertFile != null && baoDeploy.clientKeyFile != null;
# The root-only fetch. Same store and server CA as `servicesCertEnvironment`,
# but the hive's reader identity: a host fronting no swarm name holds no
# but the hive's own identity: a host fronting no swarm name holds no
# services-issuer pair.
storeRootCurlTls = lib.escapeShellArgs (
lib.optionals (baoDeploy.serverCaFile != null) [
@ -317,59 +213,53 @@ let
]
++ [
"--cert"
baoDeploy.clientCertFile
cfg.baoHiveIdentityCertFile
"--key"
baoDeploy.clientKeyFile
cfg.baoHiveIdentityKeyFile
]
);
# What `hive-tls-resign` issues the hive leaf with: the services leaf's
# store and server CA, presenting the hive's own identity.
hiveLeafEnvironment = {
inherit (servicesCertEnvironment) BAO_ADDR;
}
// lib.optionalAttrs (cfg.baoHiveIdentityCertFile != null) {
BAO_CLIENT_CERT = cfg.baoHiveIdentityCertFile;
}
// lib.optionalAttrs (cfg.baoHiveIdentityKeyFile != null) {
BAO_CLIENT_KEY = cfg.baoHiveIdentityKeyFile;
}
// lib.optionalAttrs (baoDeploy.serverCaFile != null) {
BAO_CACERT = baoDeploy.serverCaFile;
};
storeRetry = import ./lib/store-retry.nix { };
in
{
# Host-side TLS trust root for the self-signed gateway mode.
# Host-side TLS for the self-signed gateway mode.
#
# A bare self-signed leaf would be its own trust anchor, so every
# regeneration would be a new anchor and every consumer (agents,
# federation peers) would have to re-trust on each rotation — and a
# runtime-generated, in-container cert can't be wired into an agent's
# build-time trust store at all.
# The gateway serves a short-lived **leaf** issued by the secret store's
# `pki` mount through this hive's own role, and consumers (agents,
# federation peers) anchor on that mount's root, which ships in
# `trust-bundle.pem`. Leaf rotation never re-breaks them, and no key that
# signs a certificate exists on this host. See
# `docs/networking/gateway.md` ("Self-signed TLS") and docs/swarm/ca.md.
#
# So the issuer is a long-lived **hive CA** held on the host. The
# gateway serves a **leaf** signed by that CA (via the `tls.certDir`
# bind-mount path); agents and federation peers trust the *CA* once,
# and leaf rotation never re-breaks them. See `docs/networking/gateway.md`
# ("Self-signed TLS").
#
# That CA is self-signed by default. Under
# `swarm.ca.autoConfigure` — the all-on-one-host case — it is instead
# an intermediate issued under the swarm root (./swarm-ca.nix owns the
# why) and name-constrained to this hive's domain, so it stays the
# anchor agents pin while a peer holding only the root can validate
# everything this hive serves. Either way an existing CA is left
# alone; see the issuance comment below.
# The trust bundle still carries the retired hive CA's public `ca.pem`
# where one exists, so a leaf it signed keeps verifying until it is
# replaced.
options.services.hyperhive.deploy.hive-controller.tls = {
stateDir = lib.mkOption {
type = lib.types.str;
default = "/var/lib/hive-tls";
description = ''
Host directory holding the hive CA + gateway leaf cert for the
self-signed gateway mode. `ca.pem` (the anchor agents and
federation peers trust), `ca-key.pem` (0600, never leaves the
host), `gateway.pem` / `gateway-key.pem` (the leaf the gateway
container bind-mounts and nginx serves). Persistent so the CA
survives reboots — re-deriving it would re-break every consumer.
'';
};
caValidityDays = lib.mkOption {
type = lib.types.int;
default = 7300;
description = ''
Validity window of the hive CA in days (default ~20y). Kept long
and well beyond `leafValidityDays` so the CA outlives many leaf
rotations — the whole point of the CA is to be a stable anchor
that consumers trust once. The CA is regenerated only if missing
or already expired.
Host directory holding the gateway leaf and trust bundle for the
self-signed gateway mode: `gateway.pem` / `gateway-key.pem` (the
leaf the store issues and nginx serves) and `trust-bundle.pem` (the
anchors agents and federation peers trust). `ca.pem` and
`ca-previous.pem`, where present, are the retired hive CA's public
certificates and stay in the bundle; no CA key is kept here.
'';
};
@ -389,9 +279,9 @@ in
A **separate** credential from
{option}`services.hyperhive.deploy.bao.clientCertFile`, which is this
hive's own reader identity and whose policy reads every secret the
hive is entitled to. `swarm-services-cert` never logs in with that one:
on a hive fronting no swarm name it presents it only for the mTLS
handshake of the root fetch, since `pki/ca/pem` takes no token.
hive is entitled to, and from
{option}`services.hyperhive.deploy.hive-controller.tls.baoHiveIdentityCertFile`,
which the root fetch on a hive fronting no swarm name presents.
On a hive that mints its own store PKI a glue module supplies the leaf
as a `mkDefault`; everywhere else it is the credential an operator
@ -412,21 +302,39 @@ in
'';
};
leafValidityDays = lib.mkOption {
type = lib.types.int;
default = 30;
baoHiveIdentityCertFile = lib.mkOption {
type = lib.types.nullOr lib.types.str;
default = null;
example = "/var/lib/swarm-bao-pki/hive-leaf.pem";
description = ''
Validity window of the gateway leaf cert in days (default 30).
Short-lived by design — ahead of the CA/Browser-Forum's move
toward ~47-day max lifetimes — which bounds the blast radius of a
leaf-key compromise. The leaf is re-signed by the (stable) CA
when it is missing or near expiry; because it shares the CA
anchor, a rotation does not disturb consumer trust. Agents and
federation peers validate against the CA, not browser CA/B-forum
limits. The weekly `hive-tls-resign` timer re-signs the leaf once
it is within half its validity of expiry and propagates the new
leaf into the running gateway, so a long-uptime host renews
automatically without a reboot.
This hive's own store identity. `hive-tls-resign` presents it to ask
the secret store for the gateway leaf, and `swarm-services-cert` on a
hive fronting no swarm name presents it for the handshake of the root
fetch.
Its subject has to be
`<services.hyperhive.deploy.bao.hiveLeafCommonNamePrefix>-<hiveName>`;
that cert-auth role's policy may issue from this hive's gateway-leaf
role and nothing else.
Every hive with a self-signed gateway needs it: without it both units
fail, naming this option and
{option}`services.hyperhive.deploy.hive-controller.tls.baoHiveIdentityKeyFile`.
On the host that runs the store a glue module supplies the leaf as a
`mkDefault`; on every other host an operator places it.
A path, never a value.
'';
};
baoHiveIdentityKeyFile = lib.mkOption {
type = lib.types.nullOr lib.types.str;
default = null;
example = "/var/lib/swarm-bao-pki/hive-leaf-key.pem";
description = ''
Private key for
{option}`services.hyperhive.deploy.hive-controller.tls.baoHiveIdentityCertFile`.
Both or neither — a certificate with no key authenticates nothing.
'';
};
};
@ -444,24 +352,18 @@ in
# rebuild of every hive that is not the CA host, about a fallback
# that no longer happens.
# Generate (and rotate) the hive CA + gateway leaf before anything
# serves it. Idempotent: the CA is created once and reused; the leaf
# is re-signed on expiry under the same CA so the anchor is stable.
# The state dir and the trust bundle. Never waits on the store: the
# store's container is ordered after this unit, which writes the
# bundle it bind-mounts, so the leaf is `hive-tls-resign`'s.
systemd.services.hive-tls-ca = {
description = "Generate hive CA + gateway leaf TLS cert (self-signed mode)";
description = "Assemble the hive TLS trust bundle (self-signed mode)";
wantedBy = [ "multi-user.target" ];
# The consumer is `hive-gateway-self-signed-cert`, which copies the
# leaf into the gateway's state dir at the mode nginx can read, and
# which nginx in turn `Requires=`. So this must run first or that
# copy fails under `set -eu` and takes nginx down with it — order
# against the unit that reads the file.
# leaf out of the state dir this unit creates, and which nginx in
# turn `Requires=`. Order against the unit that reads the dir.
before = [ "hive-gateway-self-signed-cert.service" ];
requiredBy = [ "hive-gateway-self-signed-cert.service" ];
# The issuance below needs the swarm root key on disk. When this
# host generates it (single-host swarm) that unit must have run
# first; when the operator provides the material there is no unit
# to wait for, so the dependency is conditional rather than a unit
# that exists and does nothing.
# The bundle carries the swarm root where this host generates it.
#
# `swarm-services-cert` is NOT ordered against this unit in either
# direction, and the absence is load-bearing — see the cycle spelled
@ -477,8 +379,6 @@ in
# and `swarm-services-cert` restarts this unit once the root it wrote
# is new — so the bundle converges instead of being a boot behind.
after = lib.optionals swarmCaCfg.autoConfigure [ "swarm-ca.service" ];
requires = lib.optionals swarmCaCfg.autoConfigure [ "swarm-ca.service" ];
path = [ pkgs.openssl ];
serviceConfig = {
Type = "oneshot";
RemainAfterExit = true;
@ -492,164 +392,103 @@ in
install -d -m 0755 "$d"
ca="$d/ca.pem"
cak="$d/ca-key.pem"
leaf="$d/gateway.pem"
leafk="$d/gateway-key.pem"
root=${lib.escapeShellArg "${swarmCaCfg.stateDir}/root.pem"}
rootk=${lib.escapeShellArg "${swarmCaCfg.stateDir}/root-key.pem"}
prev="$d/ca-previous.pem"
marker="$d/.swarm-ca-adopted"
root=${lib.escapeShellArg "${swarmCaCfg.stateDir}/root.pem"}
svcroot="$d/swarm-services-root.pem"
# --- Adoption: a hive whose CA predates the swarm root.
#
# Runs ONLY where this host also owns the root (`autoConfigure`),
# because adoption invalidates an anchor that consumers already
# trust and they refresh on their own schedule. On one box that
# schedule is knowable; across hosts it is not, so there the
# operator does it and this unit only says so, loudly.
#
# Guarded by a marker rather than by the state of the world: the
# marker's ABSENCE is the trigger, so this fires once per hive
# instead of re-deciding every activation. That is the difference
# between a migration and a kicking machine.
if [ -s "$root" ] && [ -s "$ca" ] && [ ! -e "$marker" ] \
&& ! openssl verify -CAfile "$root" "$ca" >/dev/null 2>&1; then
${
if swarmCaCfg.autoConfigure then
''
echo "adopting the swarm CA: $ca does not chain to $root" >&2
# Keep the old CA as an anchor across the overlap. Consumers
# read the bundle, so adoption is additive before it is
# subtractive — agents pick up new trust only when their
# container restarts, which is a window even on one host.
cp "$ca" "$prev"
chmod 0644 "$prev"
rm -f "$ca" "$cak"
touch "$marker"''
else
''
echo "hive-tls: this hive's CA does not chain to the swarm root." >&2
echo " hive CA: $ca" >&2
echo " swarm root: $root" >&2
echo "Adopting the hierarchy is not automatic here: it invalidates an" >&2
echo "anchor that peers and agents on OTHER hosts still trust, and they" >&2
echo "refresh on their own schedule — only you know when that is safe." >&2
echo "To adopt: rm $ca $cak && systemctl restart hive-tls-ca.service" >&2
echo "To keep the current CA deliberately: touch $marker" >&2
exit 1''
}
# The gateway leaf comes from the store, so no key that signs a
# certificate is kept here.
if [ -e "$d/ca-key.pem" ]; then
echo "removing the retired hive CA key $d/ca-key.pem"
rm -f "$d/ca-key.pem"
fi
# --- CA: generated once and reused across leaf rotations, in
# whichever of the two shapes `caGenScript` selected.
# Regenerated only if missing or already expired (checkend 0).
# A new CA means every consumer must re-trust, so the leaf is
# dropped to force a re-sign under the fresh CA.
#
# An existing CA is never re-rooted here. A hive predating the
# swarm root carries a self-signed `ca.pem`, and swapping it for
# a swarm-issued one would break every consumer already trusting
# it — including agents, whose trust is refreshed only when their
# container restarts. Adopting the hierarchy on such a hive is
# therefore an operator step (drop the CA, let this unit
# re-issue), which is also what keeps this change non-disruptive
# to hives that never adopt it.
if [ ! -s "$ca" ] || [ ! -s "$cak" ] \
|| ! openssl x509 -in "$ca" -noout -checkend 0 >/dev/null 2>&1; then
${caGenScript}
chmod 0600 "$cak"
chmod 0644 "$ca"
rm -f "$leaf" "$leafk"
fi
# --- Leaf: (re)sign when missing, within 30 days of expiry, or no
# longer covering the configured names, always under the current
# (stable) CA.
${leafCoverage}
# ⚠️ This is the guard that runs ON THE DEPLOY — `hive-tls-resign`
# only fires from a weekly timer, so a rule enforced only there is
# up to a week late and does nothing for the rebuild that changed
# the names in the first place.
#
resign=0
{ [ -s "$leaf" ] && [ -s "$leafk" ]; } || resign=1
openssl x509 -in "$leaf" -noout -checkend 2592000 >/dev/null 2>&1 || resign=1
leavesCoverNames || resign=1
if [ "$resign" = 1 ]; then
echo "signing gateway leaf at $leaf (missing, near expiry, or missing a configured name)"
${signHiveLeaf}
fi
# --- Trust bundle: what a consumer must TRUST, as opposed to
# `ca.pem`, which is what this host SIGNS with. Why they stopped
# being the same file, and why `ca-previous.pem` stays in the set
# after an adoption, are in docs/swarm/ca.md. Three constraints
# the code cannot state:
# --- Trust bundle: what a consumer must TRUST. Why it is a set of
# anchors rather than one file is in docs/swarm/ca.md. Three
# constraints the code cannot state:
#
# Written IN PLACE, never renamed into position — containers
# bind-mount this file and a bind mount follows the inode, so a
# rename leaves every consumer holding the old one.
#
# Dropping `ca-previous.pem` is deliberately NOT done here:
# "long enough" is a deployment fact, not a unit's call.
# Dropping `ca.pem` and `ca-previous.pem` is deliberately NOT done
# here: "long enough" is a deployment fact, not a unit's call.
#
# Built as an array with `if` guards, not
# `[ -s f ] && anchors+=(f)`: under `set -e` an && list whose test
# fails IS a failing command and kills the unit — on exactly the
# hive where the optional file is legitimately absent. The
# reflexive `|| true` is worse; it also swallows a real failure to
# read the hive CA.
# hive where the optional file is legitimately absent.
#
# `swarm-services-root.pem` joins the set for the reason the swarm
# root is in it: it is an ANCHOR a consumer terminates at, not an
# intermediate. It is also what keeps this change from being a
# flag day — the swarm root stays in the bundle beside it, so a
# leaf still chaining to the old services sub-CA and one issued by
# the store both verify against the same file, and a hive can be
# rebuilt before or after its peers.
# `swarm-services-root.pem` is the store's `pki` root, which both
# the gateway leaf and the swarm-services leaf chain to. `ca.pem`
# and `ca-previous.pem` are the retired hive CA's public
# certificates, kept so a leaf they signed verifies until replaced.
bundle="$d/trust-bundle.pem"
anchors=("$ca")
anchors=()
if [ -s "$ca" ]; then anchors+=("$ca"); fi
if [ -s "$prev" ]; then anchors+=("$prev"); fi
if [ -s "$root" ]; then anchors+=("$root"); fi
if [ -s "$svcroot" ]; then anchors+=("$svcroot"); fi
cat "''${anchors[@]}" > "$bundle"
# `cat` with no arguments reads stdin, so no anchor at all is an
# empty file until `swarm-services-cert` writes the root.
if [ "''${#anchors[@]}" = 0 ]; then
echo "no trust anchor on disk yet — $bundle is empty until the store's root is fetched" >&2
: > "$bundle"
else
cat "''${anchors[@]}" > "$bundle"
fi
chmod 0644 "$bundle"
'';
};
# Weekly re-sign of the gateway leaf so short-lived leaves renew
# without depending on a reboot.
# The gateway leaf: issued by the secret store's `pki` mount through
# this hive's own role, at boot, on every deploy and from the daily
# timer below, whenever it is missing, past half its lifetime, missing
# a configured name, or not chained to the store's root.
#
# `hive-tls-ca` only re-signs at service activation (boot/rebuild); a
# long-uptime host would otherwise let a 30-day leaf lapse silently.
# This service re-signs the leaf directly (not by bouncing hive-tls-ca)
# and propagates the new leaf to nginx when the file actually changed.
# ⚠️ Nothing orders the gateway against this unit, which is what lets a
# hive holding a valid leaf start its gateway with the store sealed or
# unreachable. Only a hive with no leaf yet has a gateway that waits
# for the store: its cert import fails until this unit lands a leaf
# and starts nginx.
#
# Failure leaves the current leaf alone: every file the script writes
# is a `.new` until the store has answered with both fields.
#
# Propagation mechanism: nginx serves a *copy* of the leaf, written by
# `hive-gateway-self-signed-cert` at the mode nginx can read. Re-signing
# `hive-gateway-self-signed-cert` at the mode nginx can read. Issuing
# the source therefore changes nothing on its own — the copy has to be
# remade and nginx reloaded, which is what the two calls below do.
#
# ⚠️ Neither call is `|| true`: a swallowed propagation failure means
# the leaf rotates on disk while nginx keeps serving the old copy
# until it expires, with this unit reporting success the whole time.
# A failure here must fail the timer.
# remade and nginx reloaded, which is what the calls at the end do.
# Neither of the running-nginx calls is `|| true`: a swallowed
# propagation failure means the leaf rotates on disk while nginx keeps
# serving the old copy until it expires, with this unit reporting
# success the whole time.
systemd.services.hive-tls-resign = {
description = "Re-sign the gateway TLS leaf and reload nginx";
# hive-tls-ca must have run first so the CA key exists before we try
# to re-sign under it. On first boot `Persistent=true` on the weekly
# timer fires immediately; without this ordering the resign could race
# the CA initialisation and fail with "no such file" on the CA key.
after = [ "hive-tls-ca.service" ];
description = "Issue or renew the gateway TLS leaf from the secret store's PKI";
wantedBy = [ "multi-user.target" ];
# The store's container and its granting unit where they run here;
# ignored elsewhere, as for `swarm-services-cert`, which writes the
# store's root this unit checks the leaf against.
after = [
"hive-tls-ca.service"
"container@${baoCfg.machine}.service"
"swarm-bao-pki.service"
"swarm-bao-hive-leaf-policy.service"
"swarm-services-cert.service"
];
wants = [ "container@${baoCfg.machine}.service" ];
path = [
baoDeploy.package
pkgs.jq
pkgs.openssl
pkgs.coreutils
pkgs.systemd
];
serviceConfig = {
environment = hiveLeafEnvironment;
# ./lib/store-retry.nix: the login below fails for as long as the
# store is sealed or down.
inherit (storeRetry) startLimitBurst startLimitIntervalSec;
serviceConfig = storeRetry.serviceConfig // {
Type = "oneshot";
UMask = "0077";
SyslogIdentifier = "hive-tls-resign";
@ -657,46 +496,93 @@ in
script = ''
set -euo pipefail
d=${lib.escapeShellArg cfg.stateDir}
install -d -m 0755 "$d"
leaf="$d/gateway.pem"
leafk="$d/gateway-key.pem"
# ⚠️ EVERY leaf this host SIGNS must be covered 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. The name
# checks come from the shared snippet, so this unit and
# `hive-tls-ca` cannot disagree about what a good leaf is.
#
# The services leaf is not one of them any longer: it is issued
# by the secret store, whose key this unit does not hold and
# cannot re-sign under. `swarm-services-cert-renew` renews it,
# on its own daily timer.
${leafCoverage}
# 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 ))
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
fail() {
echo "$1" >&2
if [ -s "$leaf" ]; then
echo "the current gateway leaf at $leaf stays in place" >&2
fi
exit 1
}
if fresh "$leaf" && leavesCoverNames; then
echo "leaves valid, and covering the configured names — no resign needed"
# Checked before the leaf is: a hive without its identity is red
# even while its current leaf is still valid.
${
if hiveLeafRole == null then
''fail "services.hyperhive.hiveName is unset, so this hive has no gateway-leaf role to issue from."''
else
requireHiveIdentity "ask the store for its gateway leaf"
}
${leafCoverage}
# Half the lifetime the role grants, as for the services leaf, so
# the daily timer has half the lifetime to retry through a sealed
# store.
renewat=$(( ${toString baoDeploy.servicesPkiLeafTtlHours} * 3600 / 2 ))
reissue=0
{ [ -s "$leaf" ] && [ -s "$leafk" ]; } || reissue=1
openssl x509 -in "$leaf" -noout -checkend "$renewat" >/dev/null 2>&1 || reissue=1
leavesCoverNames || reissue=1
# A leaf the retired hive CA signed, or one under a store root since
# replaced, fails this and is re-issued.
{ [ -s "$svcroot" ] && openssl verify -CAfile "$svcroot" "$leaf" >/dev/null 2>&1; } || reissue=1
if [ "$reissue" = 0 ]; then
echo "gateway leaf valid, chained to the store's root and covering the configured names — leaving it alone"
exit 0
fi
echo "a leaf is missing, near expiry, or missing a configured name — re-signing"
before="$(sha256sum "$leaf" 2>/dev/null || true)"
err="$(mktemp)"
resp="$(mktemp "$d/gateway.json.XXXXXX")"
trap 'rm -f "$err" "$resp" "$leaf.new" "$leafk.new"' EXIT
${signHiveLeaf}
# Cert auth is a login, not a transport setting; `-token-only`
# skips the token helper, for the reason `swarm-services-cert` gives.
if ! BAO_TOKEN="$(bao login -method=cert -token-only 2>"$err")"; then
fail "could not log in to the swarm secret store with this hive's identity: $(cat "$err")"
fi
export BAO_TOKEN
after="$(sha256sum "$leaf" 2>/dev/null || true)"
if [ "$before" != "$after" ]; then
echo "requesting the gateway leaf from the store"
if ! bao write -format=json \
${lib.escapeShellArg "${baoServicesPkiMount}/issue/${toString hiveLeafRole}"} \
common_name=${lib.escapeShellArg domain} \
alt_names=${lib.escapeShellArg "*.${domain}"} \
> "$resp" 2>"$err"; then
fail "the store refused to issue the gateway leaf: $(cat "$err")"
fi
# One issuance, split, for the reasons `swarm-services-cert` gives:
# the key never enters an argv, and `// empty` keeps a missing field
# from becoming the four characters `null`. The leaf alone, without
# its issuer: the issuer is the self-signed root in the bundle.
umask 077
jq -r '.data.private_key // empty' < "$resp" > "$leafk.new"
jq -r '.data.certificate // empty' < "$resp" > "$leaf.new"
for f in "$leafk.new" "$leaf.new"; do
[ -s "$f" ] || fail "the store's response was missing a field: $f is empty"
done
mv -f "$leaf.new" "$leaf"
mv -f "$leafk.new" "$leafk"
chmod 0644 "$leaf"
chmod 0600 "$leafk"
# A gateway already running gets the copy remade and a reload. One
# that is not running — a hive that had no leaf — gets started,
# `--no-block` so a start job still queued absorbs the request.
if systemctl is-active --quiet nginx.service; then
echo "gateway leaf rotated — re-importing and reloading nginx"
systemctl restart hive-gateway-self-signed-cert.service
systemctl reload nginx.service
else
echo "gateway leaf unchanged (already up to date)"
elif [ "$(systemctl show -P LoadState nginx.service)" = loaded ]; then
echo "gateway leaf landed with nginx not running — starting it"
systemctl reset-failed nginx.service
systemctl start --no-block nginx.service
fi
'';
};
@ -796,33 +682,18 @@ in
# bundle: every other hive's swarm-service names chain to it.
# `ca/pem` takes no token; the TLS client certificate is the
# store's listener's demand, not bao's, so this presents the
# hive's reader identity and never logs in with it.
# A hive without a readable reader identity fails here, naming the
# hive's own identity and never logs in with it.
# A hive without a readable identity fails here, naming the
# options: every hive must hold one.
if [ "$want_svc" = 0 ]; then
${
if haveReaderIdentity then
''
missing=""
for f in ${
lib.escapeShellArgs [
baoDeploy.clientCertFile
baoDeploy.clientKeyFile
]
}; do
[ -r "$f" ] || missing="$missing $f"
done
if [ -n "$missing" ]; then
echo "this hive has no usable store identity: cannot read$missing." >&2
echo "place the files named by services.hyperhive.deploy.bao.clientCertFile (${baoDeploy.clientCertFile}) and services.hyperhive.deploy.bao.clientKeyFile (${baoDeploy.clientKeyFile})." >&2
exit 1
fi
if haveHiveIdentity then
requireHiveIdentity "fetch the store's ${baoServicesPkiMount} root"
+ ''
echo "no swarm service domains configured — fetching only the store's pki root"
err="$(mktemp)"
raw="$(mktemp "$d/swarm-services-root.XXXXXX")"
trap 'rm -f "$err" "$raw"' EXIT
# Interim: the reader cert is the handshake identity until each hive
# has its own narrow store identity for issuing its leaf.
if ! curl --fail --silent --show-error \
${storeRootCurlTls} --output "$raw" \
"$BAO_ADDR/v1/${baoServicesPkiMount}/ca/pem" 2>"$err"; then
@ -851,14 +722,7 @@ in
fi
exit 0''
else
''
echo "this hive has no store identity, so it cannot fetch the store's ${baoServicesPkiMount} root." >&2
echo "set services.hyperhive.deploy.bao.clientCertFile (${
if baoDeploy.clientCertFile == null then "unset" else baoDeploy.clientCertFile
}) and services.hyperhive.deploy.bao.clientKeyFile (${
if baoDeploy.clientKeyFile == null then "unset" else baoDeploy.clientKeyFile
}) and place both files." >&2
exit 1''
requireHiveIdentity "fetch the store's ${baoServicesPkiMount} root"
}
fi
@ -1075,19 +939,18 @@ in
};
};
# Daily, for the reason `swarm-services-cert-renew` below is: the leaf
# needs the store, which can be sealed or unreachable.
systemd.timers.hive-tls-resign = {
description = "Weekly gateway-leaf re-sign and propagation";
description = "Daily renewal check for the gateway TLS leaf";
wantedBy = [ "timers.target" ];
timerConfig = {
# Run weekly; Persistent=true fires a missed run on next boot if
# the timer was not active (e.g. the host was off on the scheduled
# day), preventing a dormant timer from letting the leaf lapse.
OnCalendar = "weekly";
OnCalendar = "daily";
Persistent = true;
};
};
# Daily rather than `hive-tls-resign`'s weekly, for the reason
# Daily rather than weekly, for the reason
# ./swarm-nats.nix's `swarm-bao-nats-tls` timer is: this leaf needs the
# store, which can be sealed or unreachable, so there is a retry per day
# from half-life to expiry rather than two.
@ -1100,7 +963,7 @@ in
};
};
# Signal the hive-c0re lifecycle that a hive CA exists: it bind-mounts
# Signal the hive-c0re lifecycle that a trust bundle exists: it bind-mounts
# this file (read-only, public certs ONLY — never a key) into each
# agent container so agents + their tools can trust the gateway's
# self-signed leaf, and the meta flake wires the per-agent trust
@ -1130,7 +993,7 @@ in
# variable name. The anchor was never missing; nothing pointed the queue
# client at it.
#
# Where there is no hive CA this module contributes nothing at all, and
# Where the gateway is not self-signed this module contributes nothing, and
# the clients then fall back to the platform roots — which is correct for
# a swarm fronted by a public certificate.
systemd.services.hive-c0re.environment.HIVE_C0RE_OIDC_CA_FILE =

View file

@ -621,10 +621,9 @@ let
# also what an untuned mount clamps the ROOT to, so the two collided at
# exactly the length that makes issuance impossible; and the guard below
# compares the issuer's remaining life against this number, which it can
# only do if this number exists. 720h matches
# `deploy.hive-controller.tls.leafValidityDays`, the 30 days ./hive-tls.nix
# documents for every other leaf it holds. An option, read here and by
# ./hive-tls.nix's renewal threshold, so the two cannot disagree.
# only do if this number exists. Every per-hive gateway-leaf role carries it
# too. An option, read here and by ./hive-tls.nix's renewal thresholds, so
# the two cannot disagree.
servicesPkiLeafTtlHours = baoDeploy.servicesPkiLeafTtlHours;
servicesPkiLeafTtl = "${toString servicesPkiLeafTtlHours}h";
servicesPkiLeafTtlSeconds = servicesPkiLeafTtlHours * 3600;
@ -676,6 +675,15 @@ let
}
'';
# One gateway-leaf issuer per hive in the swarm directory, for the reason
# `perHiveReaders` below is per hive. One name for the pki role, the policy
# and the cert-auth role, as for the readers.
hiveLeafIssuers = lib.mapAttrsToList (hiveName: hive: {
name = "${baoDeploy.hiveLeafPkiRolePrefix}-${hiveName}";
cn = "${baoDeploy.hiveLeafCommonNamePrefix}-${hiveName}";
inherit (hive) domain;
}) hyperhiveCfg.swarm.hives;
# The PKI mount agent client certificates are issued from. Its root is
# generated inside the store, so the agent CA's key never exists outside it.
# A mount of its own because the services mount holds exactly one issuer; a
@ -1660,6 +1668,35 @@ in
'';
};
hiveLeafCommonNamePrefix = lib.mkOption {
type = lib.types.str;
default = "swarm-bao-hive-leaf";
example = "swarm-bao-hive-leaf.svc";
description = ''
Prefix of the subject the store's per-hive gateway-leaf roles accept.
The role for hive `h` accepts `<prefix>-h` and may only issue from
`<servicesPkiMountPath>/issue/<hiveLeafPkiRolePrefix>-h`, which issues
for `h`'s domain and `*.` that domain and nothing else.
Per hive for the reason
{option}`services.hyperhive.deploy.bao.matrixTokenCommonNamePrefix` is:
one subject for every hive could ask for any hive's certificate.
⚠️ Reserved as a hive name by ./swarm.nix, per hive, like its siblings.
'';
};
hiveLeafPkiRolePrefix = lib.mkOption {
type = lib.types.str;
default = "swarm-hive-leaf";
description = ''
Prefix of the per-hive roles on
{option}`services.hyperhive.deploy.bao.servicesPkiMountPath` that each
hive's gateway leaf is issued through: `<prefix>-<hive>`. The same name
is that hive's policy and cert-auth role. Must start with `swarm-`.
'';
};
grafanaOidcCommonName = lib.mkOption {
type = lib.types.str;
default = "swarm-bao-grafana-oidc";
@ -2067,14 +2104,18 @@ in
lib.hasPrefix "swarm-" servicesPkiRoleName
&& lib.hasPrefix "swarm-" natsPkiRoleName
&& lib.hasPrefix "swarm-" agentPkiRoleName
&& lib.hasPrefix "swarm-" baoDeploy.hiveLeafPkiRolePrefix
);
message = ''
services.hyperhive.deploy.bao.servicesPkiRoleName
(${servicesPkiRoleName}),
services.hyperhive.deploy.bao.natsPkiRoleName (${natsPkiRoleName})
and services.hyperhive.deploy.bao.agentPkiRoleName
(${agentPkiRoleName}) must all start with `swarm-`: the bao granter
that writes them may write pki roles under that prefix only.
services.hyperhive.deploy.bao.natsPkiRoleName (${natsPkiRoleName}),
services.hyperhive.deploy.bao.agentPkiRoleName
(${agentPkiRoleName}) and
services.hyperhive.deploy.bao.hiveLeafPkiRolePrefix
(${baoDeploy.hiveLeafPkiRolePrefix}) must all start with `swarm-`:
the bao granter that writes them may write pki roles under that
prefix only.
'';
}
];
@ -3014,6 +3055,71 @@ in
'';
};
# A SIXTH sibling, the nats unit's shape once per hive: each hive's
# gateway-leaf role, its policy and its login role. The role allows the
# hive's domain and `*.` that domain, and no IP SANs or localhost.
systemd.services.swarm-bao-hive-leaf-policy = lib.mkIf haveGranter {
description = "write the per-hive gateway-leaf pki roles, bao policies and cert-auth roles";
after = [
"container@${cfg.machine}.service"
"swarm-bao-controller-policy.service"
]
++ granterAfter;
requires = [ "swarm-bao-pki.service" ];
wantedBy = [ "multi-user.target" ];
path = [
baoCli
pkgs.coreutils
];
environment = granterEnv;
# Same unseal wait as its siblings above.
startLimitBurst = 2880;
startLimitIntervalSec = 90000;
serviceConfig = {
Type = "oneshot";
RemainAfterExit = true;
Restart = "on-failure";
RestartSec = 30;
};
script = ''
set -euo pipefail
${granterLogin}
''
+ lib.concatMapStrings (obj: ''
bao write ${lib.escapeShellArg "${servicesPkiMountPath}/roles/${obj.name}"} \
allowed_domains=${lib.escapeShellArg "${obj.domain},*.${obj.domain}"} \
allow_bare_domains=true \
allow_subdomains=false \
allow_glob_domains=true \
allow_wildcard_certificates=true \
allow_localhost=false \
allow_any_name=false \
allow_ip_sans=false \
enforce_hostnames=true \
server_flag=true \
client_flag=false \
key_type=rsa \
key_bits=4096 \
ttl=${servicesPkiLeafTtl} \
max_ttl=${servicesPkiLeafTtl}
printf '%s' ${lib.escapeShellArg ''
path "${servicesPkiMountPath}/issue/${obj.name}" {
capabilities = ["update"]
}
''} |
bao policy write ${lib.escapeShellArg obj.name} -
bao write auth/cert/certs/${lib.escapeShellArg obj.name} \
certificate=@${tlsDir}/client-ca.pem \
allowed_common_names=${lib.escapeShellArg obj.cn} \
token_policies=${lib.escapeShellArg obj.name} \
display_name=${lib.escapeShellArg obj.cn}
'') hiveLeafIssuers;
};
# The agent PKI mount: its root, its one role, the host's copy of the
# root's certificate, and the listener's trust in it.
#
@ -3041,6 +3147,7 @@ in
"swarm-bao-forwarder-oidc-policy.service"
"swarm-bao-services-issuer-policy.service"
"swarm-bao-nats-tls-policy.service"
"swarm-bao-hive-leaf-policy.service"
]
++ granterAfter;
requires = [ "swarm-bao-pki.service" ];

View file

@ -95,8 +95,7 @@ in
default = 10950;
description = ''
Validity window of the swarm root CA in days (default ~30y).
Deliberately longer than `services.hyperhive.deploy.hive-controller.tls.caValidityDays`:
the root must outlive the hive CAs it issues, or those chains
Deliberately long: the root must outlive the hive CAs it issued, or those chains
expire out from under hives that are still perfectly happy with
their own intermediate. Rotating a root is the one operation in
this system with no partial-failure mode — it invalidates every

View file

@ -55,16 +55,17 @@ let
deployCfg.bao.granterCommonName
deployCfg.bao.natsAuthCommonName
]
# The two per-hive readers' subjects, spelled out per hive rather than as the
# prefix. The prefix alone would reserve the wrong string: the role for hive
# The per-hive subjects, spelled out per hive rather than as the prefix.
# The prefix alone would reserve the wrong string: the role for hive
# `h` accepts `<prefix>-h`, so the name a hive must not BE is that composed
# spelling, and a hive named it would present a leaf that role accepts.
#
# Both spellings are reserved for every declared hive, including the one the
# Every spelling is reserved for every declared hive, including the one the
# CN belongs to — a hive cannot be named after its own reader either.
++ lib.concatMap (hive: [
"${deployCfg.bao.matrixTokenCommonNamePrefix}-${hive}"
"${deployCfg.bao.queueAgentCommonNamePrefix}-${hive}"
"${deployCfg.bao.hiveLeafCommonNamePrefix}-${hive}"
]) hiveNames;
# Public hostnames of the swarm's own services, in declaration order.