glue-bao-tls.nix signs a third leaf. It is minted whether or not a controller runs here, because the case it serves is the one where it does not: a controller elsewhere needs a leaf from this CA and cannot sign one, so issuing it here turns "obtain a certificate out of band" into "copy this file". glue-controller-bao-identity.nix holds the pairing and nothing else -- which paths this host's controller reads. Gated on the leaf existing rather than on deploy.bao.enable, so a controller on the store's host and one three networks away with an out-of-band leaf get the same wiring; gating on the store would have made the co-located case the only supported shape. The directory comes from deploy.bao.clientCertFile rather than repeating glue-bao-tls.nix's literal, so moving the PKI moves both. module-eval gains three cases and two fixtures, because nothing asserted the PKI script before: an earlier commit added a leaf to that rendered unit and left the derivation unchanged. The fixture's CN is deliberately a value no default could supply, so "the role and the leaf both carry it" says they read one option rather than that both happen to say swarm-controller. Gates: 62 module properties hold (59 before, plus these three), on a derivation hash that actually moved -- this suite is a cache hit when only fixtures change, so an unchanged hash would have meant the cases never ran. nix fmt clean, all three scripts/check-*.sh exit 0.
143 lines
6.5 KiB
Nix
143 lines
6.5 KiB
Nix
# Glue: give the secret store a PKI of its own, and point it at it.
|
|
#
|
|
# ONE PAIRING PER FILE — `glue-<consumer>-<what>.nix`. A single module holding
|
|
# every co-location default becomes the file nobody dares change, because a
|
|
# reader cannot tell which of its rules their deployment is subject to. Each
|
|
# of these should be deletable on its own, and deleting this one leaves a
|
|
# store that takes operator-provided certificates and nothing else.
|
|
#
|
|
# ⚠️ Why the PKI lives HERE and not in ./swarm-bao.nix: the store must have no
|
|
# opinion about where its identity comes from. Minting is an opinion — the
|
|
# most consequential one available — so it belongs to the glue that decides
|
|
# this deployment self-signs, not to the service that merely serves what it is
|
|
# handed. A deployment with a real internal CA drops this file and names its
|
|
# own paths; nothing in the store changes.
|
|
#
|
|
# ⚠️ Not the hive CA and not the swarm CA. The store will eventually
|
|
# distribute both, and an authority you must already hold a certificate from
|
|
# cannot be one the store hands out — reach the store to get the CA material,
|
|
# need a cert from that CA to reach the store. This CA signs a fixed, short
|
|
# list of leaves and distributes nothing, so it cannot enter that cycle.
|
|
#
|
|
# ⚠️ Files like this are the only place a `deploy.<foo>` value may derive from
|
|
# a `deploy.<bar>.enable`. Everywhere else that is forbidden. The exception
|
|
# earns itself: the derivation happens either way, and the alternative is
|
|
# having it spread through the service modules where it is invisible.
|
|
#
|
|
# Everything is `mkDefault`. An operator naming their own paths wins.
|
|
{
|
|
pkgs,
|
|
lib,
|
|
config,
|
|
...
|
|
}:
|
|
let
|
|
hyperhiveCfg = config.services.hyperhive;
|
|
deployCfg = hyperhiveCfg.deploy;
|
|
cfg = hyperhiveCfg.swarm.bao;
|
|
|
|
# Host-side, outside the container's tree, for the same reason the raft data
|
|
# is: `nixos-container destroy` must not take it. Losing the CA key means
|
|
# re-issuing every client certificate in the swarm.
|
|
pkiDir = "/var/lib/swarm-bao-pki";
|
|
|
|
# What a reader calls itself to the store. The hive's name, because a bao
|
|
# cert-auth role matches on the CN — this is an interface, not a label.
|
|
clientCn = if hyperhiveCfg.hiveName != null then hyperhiveCfg.hiveName else cfg.domain;
|
|
|
|
# $1 dir $2 basename $3 CN $4 SAN or "" $5 EKU
|
|
signLeaf = pkgs.writeShellScript "swarm-bao-sign-leaf" ''
|
|
set -euo pipefail
|
|
d="$1"; base="$2"; cn="$3"; sans="$4"; eku="$5"
|
|
csr="$(mktemp "$d/$base.csr.XXXXXX")"
|
|
ext="$(mktemp "$d/$base.ext.XXXXXX")"
|
|
trap 'rm -f "$csr" "$ext"' EXIT
|
|
|
|
openssl req -newkey rsa:4096 -nodes -sha256 \
|
|
-keyout "$d/$base-key.pem" -out "$csr" -subj "/CN=$cn"
|
|
{
|
|
[ -n "$sans" ] && printf 'subjectAltName=%s\n' "$sans"
|
|
printf 'basicConstraints=critical,CA:FALSE\n'
|
|
printf 'keyUsage=critical,digitalSignature,keyEncipherment\n'
|
|
printf 'extendedKeyUsage=%s\n' "$eku"
|
|
} > "$ext"
|
|
openssl x509 -req -in "$csr" -CA "$d/ca.pem" -CAkey "$d/ca-key.pem" \
|
|
-CAcreateserial -days 3650 -sha256 -extfile "$ext" -out "$d/$base.pem"
|
|
chmod 0600 "$d/$base-key.pem"
|
|
chmod 0644 "$d/$base.pem"
|
|
'';
|
|
in
|
|
{
|
|
config = lib.mkIf (hyperhiveCfg.enable && deployCfg.bao.enable) {
|
|
services.hyperhive.deploy.bao = {
|
|
serverCertFile = lib.mkDefault "${pkiDir}/server.pem";
|
|
serverKeyFile = lib.mkDefault "${pkiDir}/server-key.pem";
|
|
clientCaFile = lib.mkDefault "${pkiDir}/ca.pem";
|
|
|
|
# A reader on this host, which happens to be the host that mints. Only
|
|
# these three are what a reader elsewhere needs placed by hand; that they
|
|
# collapse to the same CA file here is a property of self-signing, not of
|
|
# the pairing.
|
|
clientCertFile = lib.mkDefault "${pkiDir}/client.pem";
|
|
clientKeyFile = lib.mkDefault "${pkiDir}/client-key.pem";
|
|
serverCaFile = lib.mkDefault "${pkiDir}/ca.pem";
|
|
};
|
|
|
|
# Idempotent on ABSENCE, never on content. Re-issuing the CA invalidates
|
|
# every client certificate already trusting it, so a rebuild that
|
|
# "refreshed" it would lock every reader in the swarm out at once — the
|
|
# same rule the store's TPM PIN unit follows, for a sharper reason.
|
|
# Declared beside the unit it names, not in the store's module: an entry
|
|
# exists only where the unit does, and this one is minted by glue that not
|
|
# every hive runs.
|
|
services.hyperhive.swarm.otel.journaldUnits = [ "swarm-bao-pki" ];
|
|
|
|
systemd.services.swarm-bao-pki = {
|
|
description = "mint the swarm secret store's own CA and leaves";
|
|
before = [ "swarm-bao-certs.service" ];
|
|
requiredBy = [ "swarm-bao-certs.service" ];
|
|
path = [
|
|
pkgs.openssl
|
|
pkgs.coreutils
|
|
];
|
|
serviceConfig = {
|
|
Type = "oneshot";
|
|
RemainAfterExit = true;
|
|
};
|
|
script = ''
|
|
set -euo pipefail
|
|
install -d -m 0700 ${pkiDir}
|
|
|
|
if [ ! -s ${pkiDir}/ca.pem ]; then
|
|
openssl req -x509 -newkey rsa:4096 -nodes -sha256 -days 3650 \
|
|
-keyout ${pkiDir}/ca-key.pem -out ${pkiDir}/ca.pem \
|
|
-subj "/CN=swarm-bao-ca ${cfg.domain}" \
|
|
-addext "basicConstraints=critical,CA:TRUE,pathlen:0" \
|
|
-addext "keyUsage=critical,keyCertSign,cRLSign"
|
|
chmod 0600 ${pkiDir}/ca-key.pem
|
|
chmod 0644 ${pkiDir}/ca.pem
|
|
fi
|
|
|
|
# The store's own identity, and the identity of a reader on this host.
|
|
# A reader elsewhere gets its leaf from this CA out of band — that is
|
|
# what makes the store reachable from another machine at all, and why
|
|
# the CA is a file rather than a service.
|
|
[ -s ${pkiDir}/server.pem ] || ${signLeaf} ${pkiDir} server \
|
|
${lib.escapeShellArg cfg.domain} ${lib.escapeShellArg "DNS:${cfg.domain}"} serverAuth
|
|
[ -s ${pkiDir}/client.pem ] || ${signLeaf} ${pkiDir} client \
|
|
${lib.escapeShellArg clientCn} "" clientAuth
|
|
|
|
# Minted whether or not a controller runs here, because the case it
|
|
# serves is the one where it does not: a controller elsewhere needs a
|
|
# leaf from this CA and has no way to sign one. Issuing it here turns
|
|
# "obtain a certificate out of band" into "copy this file".
|
|
#
|
|
# Its own CN rather than the reader's above: the controller's policy
|
|
# lets it create roles for every hive, and the reader's leaf carries
|
|
# this hive's name.
|
|
[ -s ${pkiDir}/controller.pem ] || ${signLeaf} ${pkiDir} controller \
|
|
${lib.escapeShellArg deployCfg.bao.controllerCommonName} "" clientAuth
|
|
'';
|
|
};
|
|
};
|
|
}
|