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.
157 lines
6.9 KiB
Nix
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;
|
|
})
|
|
);
|
|
};
|
|
}
|