diff --git a/nix/host-modules/lib/hive-ca-trust.nix b/nix/host-modules/lib/hive-ca-trust.nix index e1959f93..509730c3 100644 --- a/nix/host-modules/lib/hive-ca-trust.nix +++ b/nix/host-modules/lib/hive-ca-trust.nix @@ -83,25 +83,48 @@ in # # Returns a module, not bare services: a caller already writing # `systemd.services.` cannot also write `systemd.services`. + # + # The two host-consumer flags are documented at their use sites below. trustBundle = { name, consumers, pkgs, + # A HOST systemd service rather than something inside a container. + # `caContainerPath` is a bind mount that only exists in a container, so + # a host consumer reads the host copy — which means waiting for the + # unit that writes it. In a container that wait is the container's own + # (`containerOrdering`); here nothing carries it. One flag, because a + # host source without the ordering is a race. + hostUnit ? false, + # Whether the consumer exists at all. A container caller imports this + # into the CONTAINER's module set, so it vanishes with the container. A + # host caller imports it at the host's top level, where `imports` is + # unconditional — without this, a hive with the consumer off still gets + # a bundle oneshot *and* a `systemd.services.` conjured by + # `genAttrs`, holding an `SSL_CERT_FILE` and no `ExecStart`. + enable ? true, }: let dir = "/run/${name}-ca"; bundlePath = "${dir}/trust-bundle.pem"; unit = "${name}-ca-bundle"; + source = if hostUnit then caHostPath else caContainerPath; in { _file = "hive-ca-trust.nix#trustBundle:${name}"; - config.systemd.services = lib.optionalAttrs useSelfSigned ( + config.systemd.services = lib.optionalAttrs (useSelfSigned && enable) ( { ${unit} = { description = "assemble ${name} TLS trust bundle (system CAs + hive CA)"; wantedBy = [ "multi-user.target" ]; before = map (c: "${c}.service") consumers; + # Only for a host consumer: the CA file is written at runtime by + # `hive-tls-ca.service`, and reading it directly means waiting + # for it. A container consumer reads a bind mount instead, and + # its `container@` unit carries the equivalent wait. + after = lib.optionals hostUnit [ "hive-tls-ca.service" ]; + requires = lib.optionals hostUnit [ "hive-tls-ca.service" ]; serviceConfig = { Type = "oneshot"; RemainAfterExit = true; @@ -115,7 +138,7 @@ in set -euo pipefail install -d -m 0755 ${dir} tmp=${bundlePath}.tmp - cat /etc/ssl/certs/ca-certificates.crt ${caContainerPath} > "$tmp" + cat /etc/ssl/certs/ca-certificates.crt ${source} > "$tmp" # `cat` of an empty or missing-but-mounted source exits 0, so the # result has to be inspected rather than the command trusted. if ! grep -q 'BEGIN CERTIFICATE' "$tmp"; then diff --git a/nix/host-modules/swarm-controller.nix b/nix/host-modules/swarm-controller.nix index fcc5fbfd..e57f6b97 100644 --- a/nix/host-modules/swarm-controller.nix +++ b/nix/host-modules/swarm-controller.nix @@ -35,6 +35,26 @@ let forgeCfg = config.services.hyperhive.swarm.forge; uiCfg = config.services.hyperhive.swarm.ui; + # The controller's forge client speaks TLS to `https://${forgeCfg.domain}`, + # which the gateway serves with a leaf signed by the hive CA — a CA + # generated at runtime, so nothing build-time can name it and it is not in + # the system store. `reqwest`/`rustls` resolves roots through + # `rustls-native-certs`, whose `SSL_CERT_FILE` *replaces* that store rather + # than adding to it, so without the assembled bundle every forge call fails + # `invalid peer certificate: UnknownIssuer` and agent creation dies at its + # first step. + # + # `hostUnit`: this consumer is a host service, not a container, so it reads + # the CA from the host path and the oneshot waits on `hive-tls-ca.service` + # itself. `enable`: `imports` is unconditional at the host's top level, so + # without it a hive with the controller off would get a bundle unit and a + # phantom `swarm-controller` service holding an `SSL_CERT_FILE`. + caTrust = import ./lib/hive-ca-trust.nix { + inherit lib; + tlsCfg = config.services.hyperhive.tls; + gatewayCfg = config.services.hyperhive.gateway; + }; + # The controller's own OAuth2 client. It is NOT a hive: the per-hive # clients the roster issues belong to hives, and the responder's client # belongs to the responder. One identity per principal — the rule is that @@ -139,6 +159,16 @@ let }; in { + imports = [ + (caTrust.trustBundle { + inherit pkgs; + name = "swarm-controller"; + consumers = [ "swarm-controller" ]; + hostUnit = true; + enable = cfg.enable; + }) + ]; + options.services.hyperhive.swarm.controller = { queueClientId = lib.mkOption { type = lib.types.str;