diff --git a/docs/swarm/ca.md b/docs/swarm/ca.md index 1cea1ac9..0739f7da 100644 --- a/docs/swarm/ca.md +++ b/docs/swarm/ca.md @@ -243,9 +243,9 @@ naming, because it's subtle and it bites at a distance: Two consumers, and only one of them is fine: - **`hive-tls.nix` covers agents.** It folds the root into this hive's - `trust-bundle.pem`, hive-c0re receives that path as - `HIVE_TLS_CA_PATH`, and the meta-flake renderer embeds that one file - next to every agent's flake. The bundle is the runtime-to-build-time + `trust-bundle.pem`, `glue-hive-tls-controllers.nix` hands hive-c0re that + path as `HIVE_TLS_CA_PATH`, and the meta-flake renderer embeds that one + file next to every agent's flake. The bundle is the runtime-to-build-time bridge. - **The Matrix container crosses the same bridge**, via the shared `lib/hive-ca-trust.nix` helper that `hive-forge` and `swarm-ci-runner` diff --git a/nix/host-modules/default.nix b/nix/host-modules/default.nix index 3314d1a0..a667514e 100644 --- a/nix/host-modules/default.nix +++ b/nix/host-modules/default.nix @@ -37,6 +37,7 @@ ./glue-grafana-oidc-client.nix ./glue-hive-otel-authelia.nix ./glue-hive-tls-bao-identity.nix + ./glue-hive-tls-controllers.nix ./glue-logs-auth-bao-identity.nix ./glue-metrics-auth-bao-identity.nix ./glue-matrix-ctl-bao-identity.nix diff --git a/nix/host-modules/glue-hive-tls-controllers.nix b/nix/host-modules/glue-hive-tls-controllers.nix new file mode 100644 index 00000000..07c9041f --- /dev/null +++ b/nix/host-modules/glue-hive-tls-controllers.nix @@ -0,0 +1,55 @@ +# Glue: hand the hive's trust bundle to the daemons that dial the swarm over TLS. +# +# ONE PAIRING PER FILE — hive-c0re and the swarm controller ← the trust bundle +# ./hive-tls.nix assembles. Deleting this leaves daemons that trust only the +# platform roots: hive-c0re's lifecycle cannot hand agents the bundle, and the +# swarm-queue clients die at `invalid peer certificate: UnknownIssuer` against +# an authelia signed by the swarm CA. +# +# `HIVE_TLS_CA_PATH` signals the hive-c0re lifecycle that a trust bundle exists: +# it bind-mounts the file (read-only, public certs ONLY — never a key) into each +# agent container, and the meta flake wires the per-agent trust bundle. +# `*_OIDC_CA_FILE` names the same anchor for the swarm-queue clients that mint a +# token from authelia over TLS. `swarm-queue-client` builds a bare +# `reqwest::Client`, so it trusts only the platform roots without it. Where the +# gateway is not self-signed ./hive-tls.nix contributes no bundle and nothing is +# set here, which is correct for a swarm fronted by a public certificate. +# +# ⚠️ One gate per daemon, each ANDed with the bundle existing: the gateway also +# runs on hosts with no hive and no controller, and defining an environment key +# on a unit that does not exist CREATES a unit fragment for it — inert (no +# `ExecStart`, empty `wantedBy`, never activated), and it evaluates and builds +# clean. +# +# ⚠️ Files like this are the only place a `deploy.` value may derive from +# a `deploy..enable`. Everywhere else that is forbidden. +{ + lib, + config, + ... +}: +let + hyperhiveCfg = config.services.hyperhive; + deployCfg = hyperhiveCfg.deploy; + gatewayCfg = hyperhiveCfg.gateway; + + # ./hive-tls.nix writes the bundle only where the gateway serves its own + # self-signed cert. + haveBundle = gatewayCfg.enable && gatewayCfg.useSelfSigned; + trustBundle = "${deployCfg.hive-controller.tls.stateDir}/trust-bundle.pem"; +in +{ + config = lib.mkIf haveBundle ( + lib.mkMerge [ + (lib.mkIf deployCfg.hive-controller.enable { + systemd.services.hive-c0re.environment = { + HIVE_TLS_CA_PATH = trustBundle; + HIVE_C0RE_OIDC_CA_FILE = trustBundle; + }; + }) + (lib.mkIf deployCfg.swarm-controller.enable { + systemd.services.swarm-controller.environment.SWARM_CONTROLLER_OIDC_CA_FILE = trustBundle; + }) + ] + ); +} diff --git a/nix/host-modules/hive-c0re/default.nix b/nix/host-modules/hive-c0re/default.nix index 62471e8f..24e20cd4 100644 --- a/nix/host-modules/hive-c0re/default.nix +++ b/nix/host-modules/hive-c0re/default.nix @@ -131,7 +131,7 @@ in # gateway vhost, which serves a leaf chaining to the store's pki root — # a CA generated at runtime, which no build-time trust store can # contain. The queue's TLS is verified against `HIVE_C0RE_OIDC_CA_FILE` - # (../hive-tls.nix), not this bundle. + # (../glue-hive-tls-controllers.nix), not this bundle. # # ⚠️ `SSL_CERT_FILE` REPLACES the trust store, and unlike the other # consumers of this helper, hive-c0re already makes a *public*-TLS call: diff --git a/nix/host-modules/hive-tls.nix b/nix/host-modules/hive-tls.nix index 8fe21ffc..04fda83b 100644 --- a/nix/host-modules/hive-tls.nix +++ b/nix/host-modules/hive-tls.nix @@ -929,41 +929,5 @@ in Persistent = true; }; }; - - # Signal the hive-c0re lifecycle that a trust bundle exists: it bind-mounts - # this file (read-only, public certs ONLY — never a key) into each - # agent container so agents + their tools can trust the gateway's - # self-signed leaf, and the meta flake wires the per-agent trust - # bundle. No key path is ever exposed (an agent that could read one - # could mint trusted certs). - # - # ⚠️ Every line here that names another daemon's unit is gated on that - # daemon's own enable: the gateway also runs on hosts with no hive and no - # controller, and defining an environment key on a unit that does not - # exist CREATES a unit fragment for it — inert (no `ExecStart`, empty - # `wantedBy`, never activated), and it evaluates and builds clean. - systemd.services.hive-c0re.environment.HIVE_TLS_CA_PATH = - lib.mkIf hyperhiveCfg.deploy.hive-controller.enable "${cfg.stateDir}/trust-bundle.pem"; - - # The same anchor, named for the swarm-queue clients that need it when - # they mint a token from authelia over TLS. Declared HERE, beside the - # bundle, rather than in each consumer's module: the path is this - # module's fact, and two consumers re-deriving `${stateDir}/…` would be - # two places to fix when it changes. - # - # ⚠️ `swarm-queue-client` builds a bare `reqwest::Client`, so it trusts - # only the platform roots and dies at `invalid peer certificate: - # UnknownIssuer` against a swarm whose authelia is signed by the swarm - # CA. The bundle is already assembled on disk and handed to hive-c0re - # under a different variable name; this declaration is what points the - # queue client at it. - # - # Where the gateway is not self-signed this module contributes nothing, and - # the clients then fall back to the platform roots — which is correct for - # a swarm fronted by a public certificate. - systemd.services.hive-c0re.environment.HIVE_C0RE_OIDC_CA_FILE = - lib.mkIf hyperhiveCfg.deploy.hive-controller.enable "${cfg.stateDir}/trust-bundle.pem"; - systemd.services.swarm-controller.environment.SWARM_CONTROLLER_OIDC_CA_FILE = - lib.mkIf hyperhiveCfg.deploy.swarm-controller.enable "${cfg.stateDir}/trust-bundle.pem"; }; } diff --git a/nix/module-eval/core-toggle.nix b/nix/module-eval/core-toggle.nix index b92bd6bd..a3d8dad1 100644 --- a/nix/module-eval/core-toggle.nix +++ b/nix/module-eval/core-toggle.nix @@ -1011,7 +1011,7 @@ let # controller, so the host that runs it says so itself. # # Probed by what the daemon needs in order to run, not by - # `? swarm-controller`: ./host-modules/hive-tls.nix defines an + # `? swarm-controller`: ./host-modules/glue-hive-tls-controllers.nix defines an # environment key on that unit name, which leaves the attr # present-but-inert (no `ExecStart`, empty `wantedBy`) on every hive # that has a CA — see the comment there. The daemon's system user has no diff --git a/nix/module-eval/hive-tls.nix b/nix/module-eval/hive-tls.nix index fa122f80..ffda8b80 100644 --- a/nix/module-eval/hive-tls.nix +++ b/nix/module-eval/hive-tls.nix @@ -684,6 +684,30 @@ let && (t.timerConfig.Unit or "hive-tls-resign.service") == "hive-tls-resign.service" && !(remainsAfterExit leafUnit); } + { + # ./glue-hive-tls-controllers.nix. The controllers are gated apart: the + # hive host without the swarm controller sets the first two only, and a + # gateway with neither sets none. The first arm is the presence control. + name = "the trust bundle reaches each controller's environment only where that controller runs"; + ok = + let + bundle = "/var/lib/hive-tls/trust-bundle.pem"; + envOf = m: unit: m.systemd.services.${unit}.environment or { }; + hiveVars = [ + "HIVE_TLS_CA_PATH" + "HIVE_C0RE_OIDC_CA_FILE" + ]; + anyOf = + m: unit: vars: + lib.any (v: (envOf m unit) ? ${v}) vars; + in + lib.all (v: (envOf allLocal "hive-c0re").${v} == bundle) hiveVars + && (envOf allLocal "swarm-controller").SWARM_CONTROLLER_OIDC_CA_FILE == bundle + && lib.all (v: (envOf noService "hive-c0re").${v} == bundle) hiveVars + && !(anyOf noService "swarm-controller" [ "SWARM_CONTROLLER_OIDC_CA_FILE" ]) + && !(anyOf gatewayNoHive "hive-c0re" hiveVars) + && !(anyOf gatewayNoHive "swarm-controller" [ "SWARM_CONTROLLER_OIDC_CA_FILE" ]); + } { # A oneshot has no start timeout by default, so an attempt that never # ends never fails into a retry.