diff --git a/nix/host-modules/lib/hive-ca-trust.nix b/nix/host-modules/lib/hive-ca-trust.nix index 468b3bcb..5e46e878 100644 --- a/nix/host-modules/lib/hive-ca-trust.nix +++ b/nix/host-modules/lib/hive-ca-trust.nix @@ -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.` 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; + }) + ); + }; } diff --git a/nix/host-modules/swarm-authelia.nix b/nix/host-modules/swarm-authelia.nix index cd63c2a6..753b067f 100644 --- a/nix/host-modules/swarm-authelia.nix +++ b/nix/host-modules/swarm-authelia.nix @@ -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.`, `chat.`, `auth.`). 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:. 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"; diff --git a/nix/host-modules/swarm-nats.nix b/nix/host-modules/swarm-nats.nix index bfb83083..a29121e5 100644 --- a/nix/host-modules/swarm-nats.nix +++ b/nix/host-modules/swarm-nats.nix @@ -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" ];