diff --git a/nix/host-modules/swarm-bao.nix b/nix/host-modules/swarm-bao.nix index 1289efdc..dd752c28 100644 --- a/nix/host-modules/swarm-bao.nix +++ b/nix/host-modules/swarm-bao.nix @@ -243,6 +243,55 @@ let # value on the reading side. credentialMountPath = "secret"; + # The PKI engine the swarm's *service* certificates are to be issued from, + # named once for the same reason `credentialMountPath` is: the role below, + # the grant that points at `pki/issue/`, and the `secrets enable` in the + # bootstrap unit all have to spell it the same way. + # + # ⚠️ NOT bao's own client-auth PKI. That one is ./glue-bao-tls.nix's + # self-signed CA under `/var/lib/swarm-bao-pki`, and it stays outside the + # store permanently: bao cannot issue the credential that opens bao. + # + # Nothing issues through this mount yet — see the role below. + servicesPkiMountPath = "pki"; + + # The role every swarm-service certificate will be issued through, and the + # whole narrowing of this mount. A role rather than a name-constrained + # sub-CA: the engine enforces the same "these names and no others" at issue + # time that ./swarm-ca.nix builds into x509 `nameConstraints` today, so a + # second CA in the chain would re-implement a check that already happens. + servicesPkiRoleName = "swarm-services"; + + # The names that role may issue for. Read out of the swarm-tier option + # rather than assembled here, exactly as ./swarm-ca.nix:45 and + # ./hive-tls.nix:16 read it — the thing that narrows what may be issued and + # the leaf that carries those names as SANs must agree, and a second module + # composing its own list is how they stop agreeing. + swarmServiceDomains = hyperhiveCfg.swarm.serviceDomains; + + # The identity that will ask this mount for a certificate, once something + # does. A THIRD principal rather than either existing one, for the reason + # `secretPublisherCommonName` gives for being a second: the controller may + # rewrite every hive's policy and login role, and a unit whose whole job is + # renewing one server certificate has no business holding that. + # + # `update` and not `create`: bao's issue endpoint is a POST to an existing + # path, which is `update` in ACL terms — `create` here would grant nothing + # and read as though it did. + # + # ⚠️ NOTHING IS ATTACHED TO THIS POLICY. A bao policy grants only through a + # token that carries it, so until a login role names it this is an inert + # declaration of intent — which is the point: the cert-auth role that + # attaches it needs a leaf carrying its CN, and minting that is + # ./glue-bao-tls.nix's job, one step further along than this one. + servicesIssuerPolicyName = "swarm-services-issuer"; + + servicesIssuerPolicyText = '' + path "${servicesPkiMountPath}/issue/${servicesPkiRoleName}" { + capabilities = ["update"] + } + ''; + # Every listener serves the same identity: they differ in which address # they answer on, not in who they are. Client verification is separate and # optional — a store with no `clientCaFile` still serves TLS, it just does @@ -1016,6 +1065,66 @@ in *'"${credentialMountPath}/"'*) ;; *) bao secrets enable -path=${credentialMountPath} kv-v2 ;; esac + + # The PKI engine the swarm's service certificates are to come from. + # Asked rather than attempted for the same reason as the mount above: + # `secrets enable` errors on a path already in use, so a second + # rebuild would fail a unit that has nothing left to do. + # + # ⚠️ Deliberately EMPTY: nothing here generates an issuer into it, so + # the role below can be written but cannot yet issue. That is the + # whole shape of this step — the mount, its narrowing and its grant + # exist, and the minter that uses them arrives separately. Undoing it + # is `bao secrets disable ${servicesPkiMountPath}`. + case "$mounts" in + *'"${servicesPkiMountPath}/"'*) ;; + *) bao secrets enable -path=${servicesPkiMountPath} pki ;; + esac + + # `bao write` is an upsert, so this re-asserts the role on every + # rebuild rather than failing on one that exists — same shape as the + # cert-auth roles below, and the reason the mount needs the `case` + # above while this does not. + # + # Every narrowing is load-bearing, and three of them turn OFF a + # default that is on: + # * `allow_subdomains` — the x509 constraint ./swarm-ca.nix writes + # permits a whole subtree per name; a swarm service is an exact + # hostname, so the role is the narrower of the two. + # * `allow_localhost` — bao's own default is `true`, which would + # make this role issue for a name nobody configured. + # * `allow_ip_sans` — the sub-CA excludes both IP subtrees + # explicitly (./swarm-ca.nix:260-276); an unconstrained name type + # is a name type this issuer is unconstrained for. + # `server_flag` alone because that is what today's leaf carries + # (`extendedKeyUsage=serverAuth`, ./hive-tls.nix:144), and rsa:4096 + # because that is the key the current minter generates. + # + # An empty `swarm.serviceDomains` renders an empty `allowed_domains`, + # which with `allow_any_name=false` is a role that issues nothing — + # the same outcome as ./swarm-ca.nix's "nothing to issue for" exit, + # reached without a branch. + bao write ${lib.escapeShellArg "${servicesPkiMountPath}/roles/${servicesPkiRoleName}"} \ + allowed_domains=${lib.escapeShellArg (lib.concatStringsSep "," swarmServiceDomains)} \ + allow_bare_domains=true \ + allow_subdomains=false \ + allow_glob_domains=false \ + 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 + + # Idempotent for the same reason the controller's policy above is: a + # rebuild re-asserts it. Written here rather than in a unit of its + # own because it attaches to nothing yet — the sibling-unit rule + # below is about a second *principal* that logs in, and this policy + # has no login role until the leaf carrying its CN exists. + printf '%s' ${lib.escapeShellArg servicesIssuerPolicyText} | + bao policy write ${lib.escapeShellArg servicesIssuerPolicyName} - '' + lib.optionalString (baoDeploy.clientCaFile != null) ''