hyperhive/nix/host-modules/lib/hive-ca-trust.nix
atlas 850cc2c1d3 fix(#3363): give the queue's auth responder the hive CA
The responder introspects authelia over https by name. It had no CA trust
at all, so the handshake failed UnknownIssuer, introspection failed, and it
denied every client -- surfacing at the controller as a 60s
authorization-violation loop, two layers from the cause.

Adds a shared trustBundle helper to lib/hive-ca-trust.nix rather than a
fifth hand-rolled concat. Four containers were each assembling this
themselves, 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 trust nothing at all.

The helper fixes both halves of that. requires goes on the consumer, so a
failed bundle stops it and the dependency is visible in systemctl status
where someone debugging a TLS failure looks. And the script assembles to a
temp path, checks the result actually contains a certificate, and only then
moves it into place -- cat of an empty bind exits 0, so set -e does not
catch it and a partial bundle must never appear under the final name.

Returns a module rather than bare services: a caller that already writes
systemd.services.<consumer> cannot also write systemd.services in the same
attrset.
2026-08-17 19:57:44 +02:00

157 lines
6.9 KiB
Nix

# 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@<name>` 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@<name>` 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 <consumer>`, 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.<consumer> = …`
# 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;
})
);
};
}