diff --git a/nix/host-modules/swarm-bao.nix b/nix/host-modules/swarm-bao.nix index 2155118b..83f77383 100644 --- a/nix/host-modules/swarm-bao.nix +++ b/nix/host-modules/swarm-bao.nix @@ -219,6 +219,39 @@ let clientCaPath = "${tlsDir}/client-ca.pem"; + # The store's own CLI, on the host, with this deployment's coordinates already + # in the environment. Only the wrapper goes on `PATH` — `wrapProgram` renames + # the real binary, so there is no unwrapped `bao` to reach by accident and + # produce a connection that names no cause. + # + # `--set-default`, not `--set`: an operator pointing `BAO_ADDR` at another + # store must win. A wrapper that overrode an explicit environment would be the + # same class of surprise it exists to remove. + baoCli = + let + # The reader's identity `glue-bao-tls` already mints on this host, so + # cert-auth login needs nothing typed either. A token stays the + # operator's to supply — a wrapper cannot conjure one. + wrapArgs = [ + "--set-default BAO_ADDR ${lib.escapeShellArg "https://${cfg.domain}:${toString cfg.port}"}" + ] + ++ lib.optional ( + baoDeploy.serverCaFile != null + ) "--set-default BAO_CACERT ${lib.escapeShellArg baoDeploy.serverCaFile}" + ++ lib.optionals (baoDeploy.clientCertFile != null && baoDeploy.clientKeyFile != null) [ + "--set-default BAO_CLIENT_CERT ${lib.escapeShellArg baoDeploy.clientCertFile}" + "--set-default BAO_CLIENT_KEY ${lib.escapeShellArg baoDeploy.clientKeyFile}" + ]; + in + pkgs.symlinkJoin { + name = "bao-hive"; + paths = [ baoDeploy.package ]; + nativeBuildInputs = [ pkgs.makeWrapper ]; + postBuild = '' + wrapProgram $out/bin/bao ${lib.concatStringsSep " " wrapArgs} + ''; + }; + extraListeners = lib.listToAttrs ( lib.imap1 ( i: addr: @@ -678,6 +711,12 @@ in }) (lib.mkIf (hyperhiveCfg.enable && deployCfg.bao.enable) { + # The wrapper alone, never `baoDeploy.package`: openbao's own module + # installs the CLI *inside* the container, and this host had none at all, + # so an operator either hopped into the container or re-typed the + # addresses on every command. + environment.systemPackages = [ baoCli ]; + # The in-container unit plus the two host-side ones this module defines. # `swarm-bao-pki` and `swarm-bao-matrix-token` are declared by the glue # modules that create them, per the option's own rule — and a name diff --git a/nix/module-eval.nix b/nix/module-eval.nix index a229fe44..d51f92fe 100644 --- a/nix/module-eval.nix +++ b/nix/module-eval.nix @@ -282,6 +282,13 @@ let deploy.bao.serverCaFile = lib.mkForce "/etc/pki/store-server-ca.pem"; }; + # The host's `bao` wrapper, pulled apart once so each case below names one + # property instead of a conjunction — a failing conjunction says only that + # something is wrong. + baoHostPackages = controllerTwoCas.environment.systemPackages; + baoWrapper = lib.findFirst (p: (p.name or "") == "bao-hive") null baoHostPackages; + baoWrapperCmd = if baoWrapper == null then "" else (baoWrapper.buildCommand or ""); + # 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 @@ -657,6 +664,45 @@ let && !builtins.elem "hive-client-ca.pem:/etc/pki/store-server-ca.pem" s.swarm-controller.serviceConfig.LoadCredential && m.deploy.swarm-controller.hiveClientCaFile == m.deploy.bao.clientCaFile; } + { + # Same two-CA fixture, for the same reason: the wrapper verifies the + # STORE, so it takes `serverCaFile`. On a self-signing deployment both + # options name one file and either would pass; here the client CA in that + # slot is a case this arm fails. + # + # ⚠️ The package itself stays off `PATH` — `wrapProgram` renames the real + # binary, so an unwrapped `bao` is unreachable rather than merely + # discouraged. Operator's instruction, and the last assertion is what + # keeps a later "install the package too" from quietly undoing it. + name = "the host gets a wrapped bao CLI"; + ok = baoWrapper != null; + } + { + name = "the wrapped bao CLI carries this store's address"; + ok = lib.hasInfix "--set-default BAO_ADDR" baoWrapperCmd; + } + { + # `serverCaFile` and not `clientCaFile`: the wrapper verifies the STORE. + # On a self-signing deployment both options name one file and either + # would pass, which is why this uses the two-CA fixture. + # + # ⚠️ The flag and its VALUE together, escaped the same way the module + # escapes it: `BAO_CACERT` present and `store-server-ca.pem` present + # somewhere are two facts that do not add up to "the CA is set to that + # file", and a weaker pair of `hasInfix`es passes on a wrapper that sets + # neither to the other. + name = "the wrapped bao CLI verifies the store with the server CA"; + ok = + lib.hasInfix "--set-default BAO_CACERT ${lib.escapeShellArg "/etc/pki/store-server-ca.pem"}" baoWrapperCmd + && !lib.hasInfix "hive-clients-ca.pem" baoWrapperCmd; + } + { + # `wrapProgram` renames the real binary, so an unwrapped `bao` is + # unreachable rather than merely discouraged — operator's instruction. + # This is what keeps a later "install the package too" from undoing it. + name = "the unwrapped bao package stays off the host PATH"; + ok = !builtins.elem controllerTwoCas.services.hyperhive.deploy.bao.package baoHostPackages; + } { # Absence arm for the one above: without a store identity there is # nothing to write a role with, so handing over the authority would be