diff --git a/nix/host-modules/default.nix b/nix/host-modules/default.nix index 0ff92c8e..fd80803f 100644 --- a/nix/host-modules/default.nix +++ b/nix/host-modules/default.nix @@ -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 diff --git a/nix/host-modules/glue-bao-tls.nix b/nix/host-modules/glue-bao-tls.nix index 0004b2b5..4a4aff4f 100644 --- a/nix/host-modules/glue-bao-tls.nix +++ b/nix/host-modules/glue-bao-tls.nix @@ -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.` value may derive from # a `deploy..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 ''; }; }; diff --git a/nix/host-modules/glue-controller-bao-identity.nix b/nix/host-modules/glue-controller-bao-identity.nix new file mode 100644 index 00000000..049c3746 --- /dev/null +++ b/nix/host-modules/glue-controller-bao-identity.nix @@ -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"; + }; + }; +} diff --git a/nix/module-eval.nix b/nix/module-eval.nix index 0c80c52c..e050c80c 100644 --- a/nix/module-eval.nix +++ b/nix/module-eval.nix @@ -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