Compare commits

..
Author SHA1 Message Date
atlas
e7a5e93bbc docs: trim the trustBundle comment under the 30-line lint
Keeps the three constraints a reader cannot derive; the reasoning behind
them lives in the PR.
2026-08-17 19:57:44 +02:00
atlas
cb650f2dbb fix(#3363): give swarm-authelia the hive CA too
Same class-B defect as the queue responder: swarm-authelia-bridge
introspects authelia by name over https and the container trusted no swarm
CA, so the call could only ever fail UnknownIssuer.

This is also the prerequisite for the by-domain introspection change on
#3391 -- flipping that URL without the trust half would swap one failure
for another.
2026-08-17 19:57:44 +02:00
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
3 changed files with 116 additions and 0 deletions

View file

@ -62,4 +62,75 @@ in
after = [ "hive-tls-ca.service" ];
requires = [ "hive-tls-ca.service" ];
};
# System CAs + hive CA in one bundle, with `SSL_CERT_FILE` set on each
# consumer — for runtimes whose trust variable *replaces* the store (Go,
# rustls-native-certs). An additive one (Node's `NODE_EXTRA_CA_CERTS`,
# hive-ci) needs no bundle and should not use this.
#
# imports = [ (caTrust.trustBundle { inherit pkgs; name = "swarm-nats";
# consumers = [ "swarm-nats-auth" ]; }) ];
#
# Three constraints, each earned:
# - `requires` on the CONSUMER: `before` orders but does not gate, so a
# failed assembly otherwise leaves it running and trusting *nothing*.
# - assemble to a temp path, verify, then move: `cat` of an empty bind
# exits 0, and a partial bundle must never appear under the final name.
# - `consumers` are BARE unit names — they are `systemd.services` keys
# (no suffix) *and* go in `before`/`requires` (suffixed). Reversed, the
# edge names a unit that does not exist and systemd orders nothing.
#
# Returns a module, not bare services: a caller already writing
# `systemd.services.<consumer>` cannot also write `systemd.services`.
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;
})
);
};
}

View file

@ -64,6 +64,18 @@ let
unitName = "authelia-${instance}";
stateDir = "/var/lib/${unitName}";
tlsCfg = hyperhiveCfg.tls;
caTrust = import ./lib/hive-ca-trust.nix { inherit lib tlsCfg gatewayCfg; };
# `swarm-authelia-bridge` verifies the gateway when it introspects by name.
# Nothing in this container trusted the swarm CA, which is a runtime file no
# build-time option can name — so an https call out of here could only ever
# fail `UnknownIssuer`. Same defect the queue's responder hit.
caBundleModule = caTrust.trustBundle {
inherit pkgs;
name = cfg.machine;
consumers = [ "swarm-authelia-bridge" ];
};
# The SWARM's domain, because that is where the protected apps now live
# (`forge.<swarm>`, `chat.<swarm>`, `auth.<swarm>`). It moves in the
# same commit as `domain` below and cannot lag it: authelia validates
@ -751,12 +763,19 @@ in
};
};
# Order the container after the host CA generator, so the bind source
# exists before nspawn sets the mount up.
systemd.services."container@${cfg.machine}" = caTrust.containerOrdering;
containers.${cfg.machine} = {
autoStart = true;
ephemeral = false;
# Shared host netns, like the forge and matrix containers: the
# gateway reaches authelia at 127.0.0.1:<port>.
privateNetwork = false;
# Public trust bundle only, read-only. Empty when the gateway is not
# self-signed, so the whole trust path drops out cleanly.
bindMounts = caTrust.bindMount;
config =
{ ... }:
@ -766,6 +785,7 @@ in
inherit (networkCfg) bridgeIp;
dnsConsumers = [ "authelia-${instance}.service" ];
})
caBundleModule
];
system.stateVersion = "26.05";

View file

@ -23,6 +23,22 @@ let
clientAccount = "APP";
machine = "swarm-nats";
tlsCfg = config.services.hyperhive.tls;
gatewayCfg = config.services.hyperhive.gateway;
caTrust = import ./lib/hive-ca-trust.nix { inherit lib tlsCfg gatewayCfg; };
# The responder introspects authelia over https BY NAME. Its HTTP client is
# reqwest/rustls, and `rustls-platform-verifier` resolves roots through
# `rustls-native-certs`, which reads `SSL_CERT_FILE` — so the same assembled
# bundle the Go containers use applies here. Without it the handshake fails
# `UnknownIssuer`, introspection fails, and the responder denies *every*
# client: one missing trust anchor surfacing as `authorization violation` at
# every would-be queue user.
caBundleModule = caTrust.trustBundle {
inherit pkgs;
name = machine;
consumers = [ "swarm-nats-auth" ];
};
# Where the responder's credentials live *inside* the container, and the
# host path that resolves to. Two names for one location, because the
# host is the only place both filesystems are addressable.
@ -438,6 +454,9 @@ in
# unauthenticated interim state would be a hole rather than a
# rough edge.
privateNetwork = false;
# Binds only the public trust bundle, read-only. Empty when the gateway
# is not self-signed, so the whole trust path drops out cleanly.
bindMounts = caTrust.bindMount;
config =
{ ... }:
{
@ -450,6 +469,7 @@ in
# file exists.
dnsConsumers = [ "swarm-nats-auth.service" ];
})
caBundleModule
];
system.stateVersion = "26.05";
@ -575,6 +595,11 @@ in
# refuses to start when a bind source is missing, so one absent seed
# would take down the **whole container including the queue**, not
# merely the responder. A far larger blast radius than the fault.
# Order the container after the host CA generator, so the bind source
# exists before nspawn sets the mount up. Without it a late CA fails the
# container start outright rather than degrading.
systemd.services."container@${machine}" = caTrust.containerOrdering;
systemd.services.swarm-nats-auth-secrets = lib.mkIf responderConfigured {
description = "deliver the swarm queue responder's credentials";
before = [ "container@swarm-nats.service" ];