Watch
0
0
Fork
You've already forked hyperhive
0

Compare commits

..
Author SHA1 Message Date
atlas
c18aee8f74 bao: mint the controller's leaf, and point the controller at it
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.
2026-09-07 22:24:42 +02:00
atlas
a5dc62cecd bao: make the controller's CN an option, and give it cert options of its own
Two halves of one interface that currently exists in one file only.

The CN was a `let` binding in swarm-bao.nix. Whatever mints the
controller's leaf has to spell it identically, and that lives in another
file, so a literal in one place is an interface with no name. It becomes
`deploy.bao.controllerCommonName` -- under bao because it is a property
of the role this module writes, not a credential belonging to the
controller.

The certificate gets `deploy.swarm-controller.baoClientCertFile` /
`baoClientKeyFile` rather than reusing `deploy.bao.clientCertFile`. That
one means "this host as a reader" and carries the hive's name, while the
controller's policy lets it create roles for every hive; one certificate
serving both would hand that power to whatever else reads the store
here.

Both default to null. The glue that mints the leaf sets them with
`mkDefault`, the same way glue-bao-tls.nix already supplies the reader's
paths -- so the controller module names no path of bao's, and deleting
the glue leaves a controller that takes operator-provided ones.

Nothing reads the new options yet; the minting half is the next commit.
`module-eval`'s derivation is unchanged, which is the honest result for
a change that adds options without altering any asserted value -- it
proves the tree still evaluates, not that anything behaves differently.
2026-09-07 22:12:27 +02:00
6 changed files with 167 additions and 6 deletions

View file

@ -24,6 +24,7 @@
./hive-tls.nix
./otel.nix
./glue-bao-tls.nix
./glue-controller-bao-identity.nix
./glue-matrix-bao-token.nix
./swarm-authelia.nix
./swarm-bao.nix

View file

@ -16,8 +16,8 @@
# ⚠️ 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 exactly two
# things and distributes nothing, so it cannot enter that cycle.
# 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
@ -126,6 +126,17 @@ in
${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
'';
};
};

View file

@ -0,0 +1,41 @@
# Glue: point the swarm controller at the bao leaf minted for it.
#
# ONE PAIRING PER FILE — controller ← bao, and nothing else. Deleting this
# leaves a controller that takes operator-provided certificate paths, which is
# what every deployment that does not mint its own already does.
#
# ⚠️ The minting is NOT here. ./glue-bao-tls.nix holds the CA and signs the
# leaf, because the thing that owns a private key owns issuing from it; a
# second signer would duplicate that file's `openssl` helper to no benefit.
# What belongs here is the pairing: which paths this host's controller reads.
#
# ⚠️ Gated on the leaf existing, not on the store being enabled. A controller
# on the store's own host is one deployment; a controller three networks away
# holding a leaf issued out of band is another, and both want the same wiring.
# `deploy.bao.enable` would have made the first one the only supported shape.
#
# Everything is `mkDefault`. An operator naming their own paths wins.
{
lib,
config,
...
}:
let
hyperhiveCfg = config.services.hyperhive;
deployCfg = hyperhiveCfg.deploy;
baoDeploy = deployCfg.bao;
# Where ./glue-bao-tls.nix puts the leaves. Derived from the reader's own
# path rather than repeating that file's directory literal: an operator who
# moves the PKI moves both, and the two cannot drift apart.
haveMintedPki = baoDeploy.clientCertFile != null;
pkiDir = if haveMintedPki then builtins.dirOf baoDeploy.clientCertFile else null;
in
{
config = lib.mkIf (hyperhiveCfg.enable && deployCfg.swarm-controller.enable && haveMintedPki) {
services.hyperhive.deploy.swarm-controller = {
baoClientCertFile = lib.mkDefault "${pkiDir}/controller.pem";
baoClientKeyFile = lib.mkDefault "${pkiDir}/controller-key.pem";
};
};
}

View file

@ -154,10 +154,9 @@ let
# policy by spelling it the same way, and is itself named after it.
controllerPolicyName = "swarm-controller";
# The subject the controller's certificate must carry. Cert auth matches on
# the CN, so this is an interface rather than a label: a leaf signed by the
# right CA but minted with any other subject cannot authenticate.
controllerCn = "swarm-controller";
# An option rather than a literal because whatever mints the controller's
# leaf has to spell it identically, and that lives in another file.
controllerCn = baoDeploy.controllerCommonName;
# Derived with `bao write -output-policy`, which short-circuits the request
# and needs no server, rather than written from memory.
@ -478,6 +477,27 @@ in
'';
};
controllerCommonName = lib.mkOption {
type = lib.types.str;
default = "swarm-controller";
example = "swarm-controller.svc";
description = ''
Subject the store's `swarm-controller` cert-auth role accepts. An
interface, not a label: cert auth matches on the CN, so whatever mints
the controller's leaf has to spell it the same way.
Lives here because it is a property of the **role**, which this module
writes — not a credential belonging to the controller. Nothing outside
the store needs it except whatever issues that leaf.
⚠️ The CA this role trusts also signs each hive's reader leaf, whose CN
is the hive's own name, so a hive named `swarm-controller` would satisfy
the role. Changing this to something outside the hive-name grammar
(`[a-z0-9-]`) rules that out — at the cost of a role rename in any store
that has already run the granting unit.
'';
};
serverCaFile = lib.mkOption {
type = lib.types.nullOr lib.types.str;
default = null;

View file

@ -467,6 +467,37 @@ in
'';
};
baoClientCertFile = lib.mkOption {
type = lib.types.nullOr lib.types.str;
default = null;
example = "/var/lib/swarm-controller/bao-client.pem";
description = ''
Certificate this daemon presents to the swarm's secret store. Its
subject has to match
{option}`services.hyperhive.deploy.bao.controllerCommonName`, which is
what the store's cert-auth role matches on.
Distinct from {option}`services.hyperhive.deploy.bao.clientCertFile` on
purpose, and not an oversight: that one is **this host as a reader** and
carries the hive's name, while the controller's policy lets it create
roles for every hive. One certificate serving both would hand that power
to whatever else reads the store here.
Defaulted by `glue-controller-bao-identity.nix` where this deployment
mints its own; elsewhere the leaf is issued out of band and named here.
'';
};
baoClientKeyFile = lib.mkOption {
type = lib.types.nullOr lib.types.str;
default = null;
example = "/var/lib/swarm-controller/bao-client-key.pem";
description = ''
Private key for {option}`services.hyperhive.deploy.swarm-controller.baoClientCertFile`.
Both or neither — a certificate with no key authenticates nothing.
'';
};
queue = {
clientSecretFile = lib.mkOption {
type = lib.types.str;

View file

@ -254,6 +254,20 @@ let
baoGrantNoStore = hive {
deploy.bao.bootstrapTokenFile = "/run/secrets/bao-bootstrap.token";
};
# Store and controller on one machine, with a CN no default could supply.
# The odd value is what lets the case below tell "both ends read the same
# option" from "both ends happen to say swarm-controller".
baoControllerHere = hive {
deploy.bao.enable = true;
deploy.bao.bootstrapTokenFile = "/run/secrets/bao-bootstrap.token";
deploy.bao.controllerCommonName = "cn-marker-not-a-default";
deploy.swarm-controller.enable = true;
};
# The controller with no store, which is every spread deployment. Nothing
# mints here, so the pairing must leave the paths unset rather than name
# files this host will never have.
controllerNoStore = hive { deploy.swarm-controller.enable = true; };
# 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, and it separates
@ -527,6 +541,49 @@ let
&& lib.hasInfix "auth/cert/certs/swarm-controller" s
&& lib.hasInfix "/var/lib/swarm-bao-tls/client-ca.pem" s;
}
{
# Nothing asserted the PKI script before this, so a third leaf could be
# added to it and every case still passed — measured, not assumed: the
# commit that added one left `module-eval`'s derivation unchanged.
name = "the store mints a leaf for the controller, and the controller is pointed at it";
ok =
let
m = baoControllerHere;
pki = m.systemd.services.swarm-bao-pki.script;
in
lib.hasInfix "controller.pem" pki
&&
m.services.hyperhive.deploy.swarm-controller.baoClientCertFile
== "/var/lib/swarm-bao-pki/controller.pem"
&&
m.services.hyperhive.deploy.swarm-controller.baoClientKeyFile
== "/var/lib/swarm-bao-pki/controller-key.pem";
}
{
# What makes the one above mean something: a controller with no store
# has nothing to be pointed at. Naming a path here would be a file this
# host never gets, which fails at a TLS handshake rather than at eval.
name = "a controller on a host with no store is left without certificate paths";
ok =
let
c = controllerNoStore.services.hyperhive.deploy.swarm-controller;
in
c.baoClientCertFile == null && c.baoClientKeyFile == null;
}
{
# The CN is an interface between two files: the store writes a role that
# matches it, the PKI mints a leaf that carries it. They read one option,
# and this is what says so — the fixture's value cannot come from a
# default, so matching it in both places is not a coincidence.
name = "the cert-auth role and the minted leaf take their subject from one option";
ok =
let
m = baoControllerHere;
role = m.containers.swarm-bao.config.systemd.services.swarm-bao-controller-policy.script;
pki = m.systemd.services.swarm-bao-pki.script;
in
lib.hasInfix "cn-marker-not-a-default" role && lib.hasInfix "cn-marker-not-a-default" pki;
}
{
# The arm that makes the one above mean something. A role's trust anchor
# is the CA, so with none named there is nothing to write — and the