Renames `swarm-matrix-minter` and reshapes it around subcommands. Minting is now `swarm-matrix-ctl mint`. Running rust inside `containers.hive-matrix` is not free: it needs its own store identity, its own cert role and its own bind mounts, and every one of those is per-*container*, not per-task. A second single-purpose crate would have had to duplicate that plumbing to add one action, so the next thing that has to run in there should be a verb here rather than a new crate. The old name guaranteed the opposite. `main.rs` is clap dispatch; the minting logic moves to `mint.rs` unchanged. A bare invocation is refused: `mint` writes a credential, so "no verb" defaulting to it would make a typo in the unit mint rather than fail. The environment prefix moves with it, `MATRIX_MINTER_*` → `MATRIX_MINT_*`. Scoped to the verb and not to the binary, because a binary-scoped prefix is one the next verb has to share or widen, and a widened one never narrows again. A test asserts every variable carries the verb's prefix. The principal renames too. The cert role, bao policy, granting unit, leaf filename and `certAuthCns` entry all have to spell one string the same way, so leaving them as `swarm-matrix-minter` would have rebuilt the naming split this branch exists to remove. Renaming the nix options alongside is free here: every one of them is introduced by this PR and has never been released, so no operator config names them yet. `ExecStart` now names the verb, which is a contract between a nix string and a clap enum that fails at deploy time with no local signal. Both ends assert it: `mint_is_spelled_the_way_the_unit_invokes_it` in the crate, and a new module-eval arm reading the rendered `ExecStart`. docs/getting-started/setup.md drops the sender token from its "live on the host" list: setup does not touch this credential, so a setup guide has no reason to name it.
166 lines
7.9 KiB
Nix
166 lines
7.9 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.
|
|
# No fallback: `hiveName` is asserted set for every hyperhive host, which is
|
|
# the same condition this file's `config` is gated on. A fallback here reads
|
|
# as a second supported spelling and there is no such thing.
|
|
clientCn = hyperhiveCfg.hiveName;
|
|
|
|
# $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
|
|
|
|
# The secret publisher's, minted here for the reason the controller's
|
|
# line above gives — and it serves that case more often, not less: the
|
|
# publisher runs beside AUTHELIA, which is the one host guaranteed not
|
|
# to be this one whenever the store has a host of its own.
|
|
[ -s ${pkiDir}/secret-publisher.pem ] || ${signLeaf} ${pkiDir} secret-publisher \
|
|
${lib.escapeShellArg deployCfg.bao.secretPublisherCommonName} "" clientAuth
|
|
|
|
# The matrix container's `swarm-matrix-ctl`. Minted unconditionally like the two
|
|
# above, and for the third variant of the same reason: the homeserver
|
|
# is a swarm singleton, so on every hive but the one running it this
|
|
# leaf is the file an operator copies rather than a file anything
|
|
# local reads.
|
|
#
|
|
# Its own CN, not the reader's: the reader's leaf carries this hive's
|
|
# name and its policy reads the whole store, while this principal may
|
|
# only write one path — which is the entire point of giving the
|
|
# container an identity instead of lending it the hive's.
|
|
[ -s ${pkiDir}/matrix-ctl.pem ] || ${signLeaf} ${pkiDir} matrix-ctl \
|
|
${lib.escapeShellArg deployCfg.bao.matrixCtlCommonName} "" clientAuth
|
|
'';
|
|
};
|
|
};
|
|
}
|