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:
parent
52037133e2
commit
5526daab97
16 changed files with 859 additions and 628 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
'';
|
||||
};
|
||||
};
|
||||
|
|
|
|||
34
nix/host-modules/glue-hive-tls-bao-identity.nix
Normal file
34
nix/host-modules/glue-hive-tls-bao-identity.nix
Normal 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";
|
||||
};
|
||||
};
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 =
|
||||
|
|
|
|||
|
|
@ -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" ];
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -63,6 +63,21 @@ let
|
|||
deploy.bao.agentPkiRoleName = "agent";
|
||||
};
|
||||
|
||||
# A gateway-leaf role prefix the granter's `roles/swarm-*` does not reach.
|
||||
baoGranterOddHiveLeafRole = hive {
|
||||
deploy.bao.enable = true;
|
||||
deploy.bao.hiveLeafPkiRolePrefix = "hive-leaf";
|
||||
};
|
||||
|
||||
# The store with a second hive in its directory, at a domain that is not
|
||||
# `<name>.<swarm.domain>`, so each hive's gateway-leaf grant can be told
|
||||
# apart from the other's.
|
||||
baoGrantTwoHives = hive {
|
||||
deploy.bao.enable = true;
|
||||
deploy.bao.bootstrapTokenFile = "/run/secrets/bao-bootstrap.token";
|
||||
swarm.hives.h2.domain = "h2.example.org";
|
||||
};
|
||||
|
||||
# The store and the token, with no CA to trust. `mkForce` because the PKI
|
||||
# glue supplies one by default here — this is the deployment that brings its
|
||||
# own certificates and has not named the authority yet, in which nothing can
|
||||
|
|
@ -151,7 +166,7 @@ let
|
|||
_: u: (u.environment.BAO_CLIENT_CERT or null) == granterCertFile
|
||||
) baoGrantWithConsumers.systemd.services;
|
||||
|
||||
# The thirteen units that write a `swarm-*` grant, by name, for the discovery
|
||||
# The fourteen units that write a `swarm-*` grant, by name, for the discovery
|
||||
# control below.
|
||||
grantingUnitNames = [
|
||||
"swarm-bao-controller-policy"
|
||||
|
|
@ -167,6 +182,7 @@ let
|
|||
"swarm-bao-agent-pki"
|
||||
"swarm-bao-nats-auth-policy"
|
||||
"swarm-bao-operator-viewer-policy"
|
||||
"swarm-bao-hive-leaf-policy"
|
||||
];
|
||||
|
||||
# Comment lines dropped first: both the HCL and the scripts explain
|
||||
|
|
@ -784,24 +800,24 @@ let
|
|||
}
|
||||
{
|
||||
# A store host without the granter's pair writes its grants some other
|
||||
# way, so none of the thirteen units may exist. Without this arm
|
||||
# way, so none of the fourteen units may exist. Without this arm
|
||||
# `lib.mkIf haveGranter` could be dropped from any of them and every other
|
||||
# case here would still pass.
|
||||
name = "without the granter's pair none of the thirteen granting units render";
|
||||
name = "without the granter's pair none of the fourteen granting units render";
|
||||
ok =
|
||||
let
|
||||
s = baoGranterOptOut.systemd.services;
|
||||
in
|
||||
lib.all (unit: !(s ? ${unit})) (grantingUnitNames ++ [ "swarm-bao-granter-role" ])
|
||||
# The control: the same store with the pair renders all thirteen.
|
||||
# The control: the same store with the pair renders all fourteen.
|
||||
&& lib.all (unit: baoGrantHere.systemd.services ? ${unit}) grantingUnitNames;
|
||||
}
|
||||
{
|
||||
# 🩸 What replaced the silent skip. With no bootstrap token the thirteen still
|
||||
# 🩸 What replaced the silent skip. With no bootstrap token the fourteen still
|
||||
# render, and a refused granter fails them with the step that fixes it.
|
||||
# A store host that never named a token is told to name one, since the
|
||||
# unit that sets the granter up renders only where it has.
|
||||
name = "a store host without a bootstrap token renders the thirteen, each failing loudly with the one-time step";
|
||||
name = "a store host without a bootstrap token renders the fourteen, each failing loudly with the one-time step";
|
||||
ok =
|
||||
let
|
||||
s = baoGranterNoToken.systemd.services;
|
||||
|
|
@ -1091,6 +1107,78 @@ let
|
|||
in
|
||||
lib.hasInfix "/granter.pem ]" s && lib.hasInfix "bao-granter \"\" clientAuth" s;
|
||||
}
|
||||
{
|
||||
# One role, policy and login role per hive in the directory, each
|
||||
# naming only its own hive: the policy text in the unit is exactly the
|
||||
# two `update` grants, and each hive's block names neither the other's
|
||||
# role nor its subject.
|
||||
name = "each hive's gateway-leaf identity may issue from its own role alone, for its own domain";
|
||||
ok =
|
||||
let
|
||||
s = baoGrantTwoHives.systemd.services.swarm-bao-hive-leaf-policy.script;
|
||||
blockOf =
|
||||
h: next:
|
||||
lib.head (
|
||||
lib.splitString "pki/roles/swarm-hive-leaf-${next}" (
|
||||
lib.last (lib.splitString "pki/roles/swarm-hive-leaf-${h}" s)
|
||||
)
|
||||
);
|
||||
h1 = blockOf "h1" "h2";
|
||||
h2 = blockOf "h2" "h1";
|
||||
issues =
|
||||
h: dom: blk:
|
||||
lib.hasInfix "allowed_domains='${dom},*.${dom}'" blk
|
||||
&& lib.hasInfix "allow_ip_sans=false" blk
|
||||
&& lib.hasInfix "allow_localhost=false" blk
|
||||
&& lib.hasInfix "allow_any_name=false" blk
|
||||
&& lib.hasInfix "max_ttl=720h" blk
|
||||
&& lib.hasInfix "bao policy write swarm-hive-leaf-${h} -" blk
|
||||
&& lib.hasInfix "auth/cert/certs/swarm-hive-leaf-${h}" blk
|
||||
&& lib.hasInfix "allowed_common_names=swarm-bao-hive-leaf-${h}" blk
|
||||
&& lib.hasInfix "token_policies=swarm-hive-leaf-${h}" blk;
|
||||
in
|
||||
grantsIn s == [
|
||||
{
|
||||
path = "pki/issue/swarm-hive-leaf-h1";
|
||||
caps = [ "update" ];
|
||||
}
|
||||
{
|
||||
path = "pki/issue/swarm-hive-leaf-h2";
|
||||
caps = [ "update" ];
|
||||
}
|
||||
]
|
||||
&& issues "h1" "h1.t.local" h1
|
||||
&& issues "h2" "h2.example.org" h2
|
||||
&& !(lib.hasInfix "h2" h1)
|
||||
&& !(lib.hasInfix "h1" h2);
|
||||
}
|
||||
{
|
||||
# The minting side, and the hive's own path to it: the store host signs
|
||||
# its hive's leaf under the composed subject and points the gateway-leaf
|
||||
# unit at it.
|
||||
name = "the PKI unit signs this hive's gateway-leaf identity, and the hive presents it";
|
||||
ok =
|
||||
let
|
||||
s = baoGrantHere.systemd.services.swarm-bao-pki.script;
|
||||
t = baoGrantHere.services.hyperhive.deploy.hive-controller.tls;
|
||||
in
|
||||
lib.hasInfix "/hive-leaf.pem ]" s
|
||||
&& lib.hasInfix "swarm-bao-hive-leaf-h1 \"\" clientAuth" s
|
||||
&& t.baoHiveIdentityCertFile == "/var/lib/swarm-bao-pki/hive-leaf.pem"
|
||||
&& t.baoHiveIdentityKeyFile == "/var/lib/swarm-bao-pki/hive-leaf-key.pem"
|
||||
# Its own leaf, not the reader's or the services issuer's.
|
||||
&& t.baoHiveIdentityCertFile != baoGrantHere.services.hyperhive.deploy.bao.clientCertFile
|
||||
&& t.baoHiveIdentityCertFile != t.baoClientCertFile;
|
||||
}
|
||||
{
|
||||
name = "a gateway-leaf role prefix outside swarm-* is refused, naming the option";
|
||||
ok =
|
||||
let
|
||||
names = a: lib.hasInfix "services.hyperhive.deploy.bao.hiveLeafPkiRolePrefix" a.message;
|
||||
in
|
||||
lib.any (a: !a.assertion && names a) baoGranterOddHiveLeafRole.assertions
|
||||
&& !(lib.any (a: !a.assertion && names a) baoGrantHere.assertions);
|
||||
}
|
||||
{
|
||||
# The granter writes pki roles through `roles/swarm-*` only, so a role
|
||||
# named otherwise is refused at eval rather than 403'd at deploy.
|
||||
|
|
@ -1304,8 +1392,8 @@ let
|
|||
}
|
||||
{
|
||||
# What makes the case above mean something: discovery by the granter's
|
||||
# certificate reaches all thirteen units, and each yields calls.
|
||||
name = "the granter-policy check sees all thirteen granting units, and parses calls from each";
|
||||
# certificate reaches all fourteen units, and each yields calls.
|
||||
name = "the granter-policy check sees all fourteen granting units, and parses calls from each";
|
||||
ok =
|
||||
lib.sort lib.lessThan (lib.attrNames granterUnits) == lib.sort lib.lessThan grantingUnitNames
|
||||
&& lib.all (u: baoCalls u.script != [ ]) (lib.attrValues granterUnits)
|
||||
|
|
|
|||
|
|
@ -3,7 +3,8 @@
|
|||
#
|
||||
# The swarm-services leaf's renewal: a timer that really re-runs the
|
||||
# issuance, at a threshold read off the store role's lifetime. And the
|
||||
# store's root on a hive that asks it for no leaf.
|
||||
# store's root on a hive that asks it for no leaf. And the gateway leaf,
|
||||
# issued by the store through the hive's own role, with no hive CA key.
|
||||
{
|
||||
pkgs,
|
||||
lib,
|
||||
|
|
@ -38,15 +39,17 @@ let
|
|||
};
|
||||
|
||||
# Fronting none of the swarm's names, reading a store it does not run,
|
||||
# with the reader identity placed by hand.
|
||||
# with the reader identity and the hive's own identity placed by hand.
|
||||
noService = hive {
|
||||
deploy.bao.clientCertFile = "/etc/pki/bao-client.pem";
|
||||
deploy.bao.clientKeyFile = "/etc/pki/bao-client-key.pem";
|
||||
deploy.bao.serverCaFile = "/etc/pki/bao-ca.pem";
|
||||
deploy.hive-controller.tls.baoHiveIdentityCertFile = "/etc/pki/bao-hive.pem";
|
||||
deploy.hive-controller.tls.baoHiveIdentityKeyFile = "/etc/pki/bao-hive-key.pem";
|
||||
};
|
||||
noSvcFetch = noService.systemd.services.swarm-services-cert.script;
|
||||
|
||||
# The same hive with no reader identity at all.
|
||||
# The same hive with no store identity at all.
|
||||
noIdentity = hive { };
|
||||
noIdFetch = noIdentity.systemd.services.swarm-services-cert.script;
|
||||
|
||||
|
|
@ -60,6 +63,8 @@ let
|
|||
|
||||
units = allLocal.systemd.services;
|
||||
boot = units.swarm-services-cert;
|
||||
caUnit = units.hive-tls-ca;
|
||||
leafUnit = units.hive-tls-resign;
|
||||
timer = allLocal.systemd.timers.swarm-services-cert-renew;
|
||||
|
||||
# What the timer starts, read off the timer rather than assumed from its
|
||||
|
|
@ -151,13 +156,16 @@ let
|
|||
&& lib.hasInfix ''if [ -s "$svcroot" ]; then anchors+=("$svcroot"); fi'' noService.systemd.services.hive-tls-ca.script;
|
||||
}
|
||||
{
|
||||
name = "the root fetch reaches the store with the hive's own reader identity and server CA";
|
||||
# The hive's own identity, not its reader identity: the reader's policy
|
||||
# reads every secret the hive is entitled to.
|
||||
name = "the root fetch reaches the store with the hive's own identity and server CA";
|
||||
ok =
|
||||
lib.hasInfix "--cacert ${lib.escapeShellArg "/etc/pki/bao-ca.pem"}" noSvcFetch
|
||||
&& lib.hasInfix (
|
||||
"--cert ${lib.escapeShellArg "/etc/pki/bao-client.pem"}"
|
||||
+ " --key ${lib.escapeShellArg "/etc/pki/bao-client-key.pem"}"
|
||||
) noSvcFetch;
|
||||
"--cert ${lib.escapeShellArg "/etc/pki/bao-hive.pem"}"
|
||||
+ " --key ${lib.escapeShellArg "/etc/pki/bao-hive-key.pem"}"
|
||||
) noSvcFetch
|
||||
&& !(lib.hasInfix "/etc/pki/bao-client.pem" (noSvcBranch noSvcFetch));
|
||||
}
|
||||
{
|
||||
# No identity to present: the unit fails, naming both options, rather
|
||||
|
|
@ -166,11 +174,11 @@ let
|
|||
name = "a hive fronting no swarm name with no store identity fails the unit, naming both options";
|
||||
ok =
|
||||
noIdentity.services.hyperhive.swarm.localServiceDomains == [ ]
|
||||
&& noIdentity.services.hyperhive.deploy.bao.clientCertFile == null
|
||||
&& noIdentity.services.hyperhive.deploy.hive-controller.tls.baoHiveIdentityCertFile == null
|
||||
&& !(lib.hasInfix "/v1/pki/ca/pem" noIdFetch)
|
||||
&& lib.hasInfix "services.hyperhive.deploy.bao.clientCertFile" (noSvcBranch noIdFetch)
|
||||
&& lib.hasInfix "services.hyperhive.deploy.bao.clientKeyFile" (noSvcBranch noIdFetch)
|
||||
&& lib.hasSuffix "exit 1" (noSvcBranch noIdFetch)
|
||||
&& lib.hasInfix "baoHiveIdentityCertFile (unset)" (noSvcBranch noIdFetch)
|
||||
&& lib.hasInfix "baoHiveIdentityKeyFile (unset)" (noSvcBranch noIdFetch)
|
||||
&& lib.hasSuffix "exit 1" (lib.removeSuffix "\n" (noSvcBranch noIdFetch))
|
||||
&& !(lib.hasInfix "exit 0" (noSvcBranch noIdFetch))
|
||||
&& !(lib.hasInfix "not staged" noIdFetch)
|
||||
&& lib.hasInfix "/v1/pki/ca/pem" (noSvcBranch noSvcFetch);
|
||||
|
|
@ -179,13 +187,11 @@ let
|
|||
# An identity named but not on disk fails before curl, naming the paths.
|
||||
name = "a hive fronting no swarm name whose identity files are absent fails the unit, naming the paths";
|
||||
ok =
|
||||
lib.hasInfix "for f in /etc/pki/bao-client.pem /etc/pki/bao-client-key.pem; do" (
|
||||
noSvcBranch noSvcFetch
|
||||
)
|
||||
lib.hasInfix "for f in /etc/pki/bao-hive.pem /etc/pki/bao-hive-key.pem; do" (noSvcBranch noSvcFetch)
|
||||
&& lib.hasInfix ''[ -r "$f" ] || missing="$missing $f"'' (noSvcBranch noSvcFetch)
|
||||
&& lib.hasInfix "cannot read$missing" (noSvcBranch noSvcFetch)
|
||||
&& lib.hasInfix "clientCertFile (/etc/pki/bao-client.pem)" (noSvcBranch noSvcFetch)
|
||||
&& lib.hasInfix "clientKeyFile (/etc/pki/bao-client-key.pem)" (noSvcBranch noSvcFetch);
|
||||
&& lib.hasInfix "baoHiveIdentityCertFile (/etc/pki/bao-hive.pem)" (noSvcBranch noSvcFetch)
|
||||
&& lib.hasInfix "baoHiveIdentityKeyFile (/etc/pki/bao-hive-key.pem)" (noSvcBranch noSvcFetch);
|
||||
}
|
||||
{
|
||||
# The fetch retries against a sealed store; a gateway `Requires=` on it
|
||||
|
|
@ -201,6 +207,113 @@ let
|
|||
in
|
||||
!(holds noService) && holds allLocal;
|
||||
}
|
||||
{
|
||||
# The controls are the store's PKI unit, which still mints its own CA
|
||||
# with `openssl req`, and the swarm root's unit, which still reads
|
||||
# `root-key.pem`: absence here is not a matcher that cannot match.
|
||||
name = "no hive TLS unit generates a CA, signs with a CA key or reads the swarm root key";
|
||||
ok =
|
||||
let
|
||||
hasCaWork =
|
||||
s:
|
||||
lib.any (p: lib.hasInfix p s) [
|
||||
"openssl req"
|
||||
"-CAkey"
|
||||
"root-key.pem"
|
||||
];
|
||||
pkiScript = units.swarm-bao-pki.script;
|
||||
rootScript = units.swarm-ca.script;
|
||||
in
|
||||
!(hasCaWork caUnit.script)
|
||||
&& !(hasCaWork leafUnit.script)
|
||||
&& !(hasCaWork noService.systemd.services.hive-tls-ca.script)
|
||||
&& lib.hasInfix "openssl req" pkiScript
|
||||
&& lib.hasInfix "root-key.pem" rootScript;
|
||||
}
|
||||
{
|
||||
name = "a retired hive CA key is deleted, and its public certificate stays in the trust bundle";
|
||||
ok =
|
||||
lib.hasInfix ''rm -f "$d/ca-key.pem"'' caUnit.script
|
||||
&& lib.hasInfix ''ca="$d/ca.pem"'' caUnit.script
|
||||
&& lib.hasInfix ''if [ -s "$ca" ]; then anchors+=("$ca"); fi'' caUnit.script
|
||||
&& lib.hasInfix ''if [ -s "$prev" ]; then anchors+=("$prev"); fi'' caUnit.script
|
||||
&& lib.hasInfix ''cat "''${anchors[@]}" > "$bundle"'' caUnit.script;
|
||||
}
|
||||
{
|
||||
# The store host's hive presents the leaf ./glue-bao-tls.nix mints for
|
||||
# it; not the reader's, not the services issuer's.
|
||||
name = "the gateway leaf is issued through this hive's own role, presenting its own identity";
|
||||
ok =
|
||||
let
|
||||
t = allLocal.services.hyperhive.deploy.hive-controller.tls;
|
||||
in
|
||||
lib.hasInfix "pki/issue/swarm-hive-leaf-h1" leafUnit.script
|
||||
&& lib.hasInfix "common_name=h1.t.local" leafUnit.script
|
||||
&& lib.hasInfix "alt_names='*.h1.t.local'" leafUnit.script
|
||||
&& lib.hasInfix "bao login -method=cert -token-only" leafUnit.script
|
||||
&& leafUnit.environment.BAO_CLIENT_CERT == "/var/lib/swarm-bao-pki/hive-leaf.pem"
|
||||
&& leafUnit.environment.BAO_CLIENT_KEY == "/var/lib/swarm-bao-pki/hive-leaf-key.pem"
|
||||
&& t.baoHiveIdentityCertFile == leafUnit.environment.BAO_CLIENT_CERT
|
||||
&& leafUnit.environment.BAO_CLIENT_CERT != t.baoClientCertFile
|
||||
&& leafUnit.environment.BAO_CLIENT_CERT != allLocal.services.hyperhive.deploy.bao.clientCertFile;
|
||||
}
|
||||
{
|
||||
# Checked before the leaf, so the unit is red even while a valid leaf
|
||||
# keeps the gateway up. The control is a hive with the identity named,
|
||||
# whose script checks the files instead.
|
||||
name = "a hive with no store identity fails the gateway-leaf unit, naming both options";
|
||||
ok =
|
||||
let
|
||||
s = noIdentity.systemd.services.hive-tls-resign.script;
|
||||
before = lib.head (lib.splitString "reissue=0" s);
|
||||
ok' = noService.systemd.services.hive-tls-resign.script;
|
||||
in
|
||||
lib.hasInfix "services.hyperhive.deploy.hive-controller.tls.baoHiveIdentityCertFile (unset)" before
|
||||
&& lib.hasInfix "services.hyperhive.deploy.hive-controller.tls.baoHiveIdentityKeyFile (unset)" before
|
||||
&& lib.hasInfix "exit 1" before
|
||||
&& lib.hasInfix "for f in /etc/pki/bao-hive.pem /etc/pki/bao-hive-key.pem; do" (
|
||||
lib.head (lib.splitString "reissue=0" ok')
|
||||
)
|
||||
&& !(lib.hasInfix "(unset)" ok');
|
||||
}
|
||||
{
|
||||
# A leaf the retired hive CA signed is re-issued from the store on the
|
||||
# first run that reaches it; one that renews in time never is.
|
||||
name = "the gateway leaf is re-issued when not chained to the store's root, and at half its role lifetime";
|
||||
ok =
|
||||
lib.hasInfix ''openssl verify -CAfile "$svcroot" "$leaf"'' leafUnit.script
|
||||
&& lib.hasInfix (renewAt 720) leafUnit.script
|
||||
&& lib.hasInfix (renewAt 48) shortTtl.systemd.services.hive-tls-resign.script
|
||||
&& lib.hasInfix ''-in "$leaf" -noout -checkend "$renewat"'' leafUnit.script;
|
||||
}
|
||||
{
|
||||
# A valid leaf on disk starts the gateway with the store sealed: nothing
|
||||
# requires or waits on the issuing unit. The control is that unit
|
||||
# itself, which does wait on the store's container.
|
||||
name = "the gateway does not wait on the gateway-leaf unit, which waits on the store";
|
||||
ok =
|
||||
(leafUnit.requiredBy or [ ]) == [ ]
|
||||
&& (leafUnit.before or [ ]) == [ ]
|
||||
&& !(lib.any (u: lib.elem "hive-tls-resign.service" ((u.requires or [ ]) ++ (u.after or [ ]))) (
|
||||
lib.attrValues (removeAttrs units [ "hive-tls-resign" ])
|
||||
))
|
||||
&& !(lib.elem "container@swarm-bao.service" ((caUnit.requires or [ ]) ++ (caUnit.after or [ ])))
|
||||
&& lib.elem "container@swarm-bao.service" leafUnit.after
|
||||
&& lib.elem "multi-user.target" leafUnit.wantedBy;
|
||||
}
|
||||
{
|
||||
# Not `RemainAfterExit`, or the timer would start an active unit and
|
||||
# renew nothing.
|
||||
name = "a daily timer re-runs the gateway-leaf unit";
|
||||
ok =
|
||||
let
|
||||
t = allLocal.systemd.timers.hive-tls-resign;
|
||||
in
|
||||
lib.elem "timers.target" t.wantedBy
|
||||
&& t.timerConfig.OnCalendar == "daily"
|
||||
&& (t.timerConfig.Unit or "hive-tls-resign.service") == "hive-tls-resign.service"
|
||||
&& !(remainsAfterExit leafUnit);
|
||||
}
|
||||
];
|
||||
in
|
||||
runGroup "hive-tls" cases
|
||||
|
|
|
|||
|
|
@ -106,6 +106,14 @@ let
|
|||
swarm.hives.qr-other.domain = "q.t.local";
|
||||
};
|
||||
|
||||
# The per-hive gateway-leaf subject, composed the same way.
|
||||
hiveNamedAfterHiveLeafSubject = hive {
|
||||
deploy.swarm-otel.enable = false;
|
||||
deploy.bao.hiveLeafCommonNamePrefix = "hl";
|
||||
swarm.hives.other.domain = "o.t.local";
|
||||
swarm.hives.hl-other.domain = "hl.t.local";
|
||||
};
|
||||
|
||||
hiveNameWithComposedWord = hive {
|
||||
deploy.swarm-otel.enable = false;
|
||||
swarm.hives."h1-agent".domain = "a.t.local";
|
||||
|
|
@ -203,6 +211,17 @@ let
|
|||
a: !a.assertion && lib.hasInfix "'qr-other'" a.message
|
||||
) hiveNamedAfterPerHiveReaderSubject.assertions;
|
||||
}
|
||||
{
|
||||
# A hive taking this spelling would present a leaf hive `other`'s
|
||||
# gateway-leaf role accepts, and could ask the store for that hive's
|
||||
# certificate. The control is the legal roster below.
|
||||
name = "a hive named after another hive's gateway-leaf subject is refused";
|
||||
ok =
|
||||
equalityGuardFired hiveNamedAfterHiveLeafSubject
|
||||
&& lib.any (
|
||||
a: !a.assertion && lib.hasInfix "'hl-other'" a.message
|
||||
) hiveNamedAfterHiveLeafSubject.assertions;
|
||||
}
|
||||
{
|
||||
# Without this the case above proves nothing: an arm that fires for every
|
||||
# roster is not a guard, and `hives` is non-empty in both fixtures.
|
||||
|
|
|
|||
Loading…
Reference in a new issue