# Shared hive-CA trust plumbing for containers that must trust the # self-signed gateway/forge leaf for *outbound* TLS (webhook delivery, # CI artifact upload, …). The hive CA is generated at runtime by the host # `hive-tls-ca.service` (see `hive-tls.nix`) — it can't be baked into a # derivation — so each such container binds the public `ca.pem` read-only # and orders its `container@` unit after `hive-tls-ca.service` so the # bind source exists before nspawn sets the mount up. # # This is the language-agnostic half (bind-mount + systemd ordering). The # *consumption* differs per runtime and stays at each call site: Node's # `NODE_EXTRA_CA_CERTS` is additive (hive-ci), Go's `SSL_CERT_FILE` replaces # the bundle so it needs a system-CAs+hive-CA concat step (hive-forge). # # Pure function — NOT a NixOS module (don't add it to the host-modules # aggregator). Call it from a module's `let`: # # caTrust = import ./lib/hive-ca-trust.nix { inherit lib tlsCfg gatewayCfg; }; # # then, in the container: # # bindMounts = { … } // caTrust.bindMount; # # systemd.services."container@hive-ci" = lib.mkMerge [ caTrust.containerOrdering … ]; # # environment.NODE_EXTRA_CA_CERTS = caTrust.caContainerPath; # consumption, per-caller # # `tlsCfg` = config.services.hyperhive.tls # `gatewayCfg` = config.services.hyperhive.gateway { lib, tlsCfg, gatewayCfg, }: let # `gateway.useSelfSigned` is the single source of truth for the # self-signed condition — no duplicated derivation. useSelfSigned = gatewayCfg.useSelfSigned; # The bundle, not `ca.pem`: the hive CA is an intermediate under the # swarm root, and openssl (which is what both consumers below sit on — # Node's `NODE_EXTRA_CA_CERTS`, Go's `SSL_CERT_FILE`) will not end a # chain at a trusted cert that isn't self-signed. `hive-tls.nix` writes # the bundle next to the CA and explains the split. caHostPath = "${tlsCfg.stateDir}/trust-bundle.pem"; caContainerPath = "/run/hive-ca/trust-bundle.pem"; in { inherit useSelfSigned caContainerPath; # Fold into the container's `bindMounts` via `//`. Binds ONLY the public # CA cert (never the `hive-tls` state dir — it holds the CA + leaf private # keys), read-only. Empty when not self-signed, so the whole trust path # drops out cleanly. bindMount = lib.optionalAttrs useSelfSigned { ${caContainerPath} = { hostPath = caHostPath; isReadOnly = true; }; }; # Fold into the caller's `container@` unit (via `lib.mkMerge` if the # caller adds its own keys, e.g. hive-ci's `TimeoutStartSec`). Orders the # container after the host `hive-tls-ca.service` so the bind source exists # before nspawn sets the mount up — a condition-skipped/late CA would # otherwise fail the container start. containerOrdering = lib.mkIf useSelfSigned { after = [ "hive-tls-ca.service" ]; requires = [ "hive-tls-ca.service" ]; }; # Assemble system CAs + the hive CA into one bundle, for runtimes whose # trust variable *replaces* the default store (Go's `SSL_CERT_FILE`, # rustls-native-certs) rather than adding to it — pointing those at # `caContainerPath` alone would drop every public anchor. # # The header above says consumption stays at the call site, and the *env # var* still does. The assembly does not: four containers were each # hand-rolling this concat, which is how they came to share one defect — # `wantedBy` + `before` express ordering but not success, so a failed # assembly let the consumer start against a missing file and end up # trusting **nothing**, which fails every outbound TLS call while the unit # looks healthy. # # Two things this does that a hand-rolled version kept getting wrong: # - `requires` on the CONSUMER, so a failed bundle stops it — and the # dependency is visible in `systemctl status `, where someone # debugging a TLS failure actually looks. # - assemble to a temp path, check the result is non-empty and actually # contains a certificate, and only then move it into place. `cat` of an # empty bind exits 0, so `set -e` alone does not catch it, and a partial # bundle must never appear under the final name. # # Returns a MODULE to import inside the container, not an attrset to splice # in — same shape as `swarm-container-resolver.nix`, and for a concrete # reason: a caller that already writes `systemd.services. = …` # cannot also write `systemd.services = …` in the same attrset, so anything # returning bare services forces the call site to be restructured. # # imports = [ (caTrust.trustBundle { inherit pkgs; name = "swarm-nats"; consumers = [ "swarm-nats-auth" ]; }) ]; # # It sets `SSL_CERT_FILE` on each consumer itself. That is the right default # for this helper's audience — runtimes whose trust variable *replaces* the # store. A runtime with an additive variable (Node's `NODE_EXTRA_CA_CERTS`, # hive-ci) needs no bundle at all and should not use this. # # `consumers` are BARE unit names (no `.service`): they are used both as # `systemd.services` attribute keys, which must not carry the suffix, and # inside `before`/`requires`, which must. Getting that backwards produces an # edge to a unit that does not exist — which systemd accepts in silence, # ordering nothing. trustBundle = { name, consumers, pkgs, }: let dir = "/run/${name}-ca"; bundlePath = "${dir}/trust-bundle.pem"; unit = "${name}-ca-bundle"; in { _file = "hive-ca-trust.nix#trustBundle:${name}"; config.systemd.services = lib.optionalAttrs useSelfSigned ( { ${unit} = { description = "assemble ${name} TLS trust bundle (system CAs + hive CA)"; wantedBy = [ "multi-user.target" ]; before = map (c: "${c}.service") consumers; serviceConfig = { Type = "oneshot"; RemainAfterExit = true; SyslogIdentifier = unit; }; path = [ pkgs.coreutils pkgs.gnugrep ]; script = '' set -euo pipefail install -d -m 0755 ${dir} tmp=${bundlePath}.tmp cat /etc/ssl/certs/ca-certificates.crt ${caContainerPath} > "$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 echo "${unit}: assembled bundle contains no certificate" >&2 exit 1 fi chmod 0644 "$tmp" mv "$tmp" ${bundlePath} ''; }; } // lib.genAttrs consumers (_: { requires = [ "${unit}.service" ]; after = [ "${unit}.service" ]; environment.SSL_CERT_FILE = bundlePath; }) ); }; }