diff --git a/docs/swarm/ca.md b/docs/swarm/ca.md index 750a16de..5f6ce360 100644 --- a/docs/swarm/ca.md +++ b/docs/swarm/ca.md @@ -148,11 +148,26 @@ Two consumers, and only one of them is fine: `HIVE_TLS_CA_PATH`, and the meta-flake renderer embeds that one file next to every agent's flake. The bundle is the runtime-to-build-time bridge. -- **The Matrix container is not.** It has no equivalent bridge, so it - trusts no swarm-internal CA and federation with a self-signed peer - does not validate. Giving it the root needs a runtime mechanism — - bind-mount plus a bundle assembled at unit start, appending to the - system bundle rather than replacing it — which is tracked separately. +- **The Matrix container crosses the same bridge**, via the shared + `lib/hive-ca-trust.nix` helper that `hive-ci` and `hive-forge` already + use: the bundle is bind-mounted read-only into the container, and a + oneshot concatenates it with the system CAs before tuwunel starts. + + The consumption differs per runtime and is the part worth knowing. + tuwunel links no openssl, which makes `SSL_CERT_FILE` look inapplicable + — it isn't. Its outbound client is `reqwest` with the `rustls` feature, + which builds a `rustls_platform_verifier::Verifier`; because tuwunel + calls `tls_certs_merge` (additive) rather than `tls_certs_only`, the + platform roots stay alongside its compiled-in webpki set. On Linux that + verifier resolves through `rustls-native-certs` → `openssl-probe`, + which reads `SSL_CERT_FILE`. + + > ⚠️ **Concatenate; never point `SSL_CERT_FILE` at the anchor alone.** + > `openssl-probe` uses it *instead of* the default store, so naming + > just the bundle would drop every public CA and break federation with + > the wider matrix network — a much bigger outage than the one being + > fixed. The same caveat applies to `hive-forge` (Go) for the same + > reason. To put the root on another host, copy the certificate to the same path there (`scp /root.pem :/root.pem`). One anchor diff --git a/nix/host-modules/hive-matrix.nix b/nix/host-modules/hive-matrix.nix index f3b62abf..add0ba73 100644 --- a/nix/host-modules/hive-matrix.nix +++ b/nix/host-modules/hive-matrix.nix @@ -7,6 +7,19 @@ let cfg = config.services.hyperhive.swarm.matrix; networkCfg = config.services.hyperhive.network; + tlsCfg = config.services.hyperhive.tls; + gatewayCfg = config.services.hyperhive.gateway; + + # Same runtime→build-time bridge hive-ci and hive-forge already cross: + # binds the hive trust bundle (which folds in the swarm root) into the + # container and orders the container after `hive-tls-ca.service`. The + # *consumption* is per-runtime and stays here — see the bundle service + # in the container config below. + caTrust = import ./lib/hive-ca-trust.nix { inherit lib tlsCfg gatewayCfg; }; + useSelfSigned = caTrust.useSelfSigned; + # tuwunel's own combined bundle, assembled at start. /run is tmpfs, so + # it is rebuilt from the current CA every boot rather than going stale. + matrixCaBundle = "/run/hive-matrix-ca/ca-bundle.crt"; swarmDomain = config.services.hyperhive.swarm.domain; # Falls back to the SWARM domain: a swarm runs one homeserver, so its # identifier belongs to the swarm rather than to whichever hive happens @@ -426,10 +439,13 @@ in privateNetwork = false; # Read-only bind of the host-managed registration token; tuwunel # reads it via systemd LoadCredential below (not directly). - bindMounts.${cfg.registrationTokenFile} = { - hostPath = cfg.registrationTokenFile; - isReadOnly = true; - }; + bindMounts = { + ${cfg.registrationTokenFile} = { + hostPath = cfg.registrationTokenFile; + isReadOnly = true; + }; + } + // caTrust.bindMount; config = { ... }: { @@ -442,20 +458,12 @@ in # all filtering; never run one in here. networking.firewall.enable = false; - # ⚠️ This container trusts no swarm-internal CA. It used to take - # per-hive root CAs, hand-pinned as nix paths, so tuwunel could - # validate *federation* TLS from a self-signed peer hive; that - # field is gone, and the swarm root that replaces it cannot be - # substituted here. `security.pki.certificateFiles` is read when - # the system is BUILT, and the swarm root is a runtime file - # (`swarm.ca.stateDir`) precisely because its key must never - # reach the store — so there is nothing build-time to name. - # - # Giving the container the swarm root therefore needs a runtime - # mechanism (bind-mount + a bundle assembled at start), which is - # a different shape than this line and is tracked as its own - # issue. Federation with a peer whose cert chains to the swarm - # root does not validate until then. + # Swarm-internal trust reaches tuwunel at RUNTIME, not via + # `security.pki.certificateFiles`. That option is read when the + # system is BUILT, and the swarm root is deliberately a runtime + # file (`swarm.ca.stateDir`) because its key must never enter the + # store — so there is nothing build-time to name. The bind-mount + # above plus the bundle service below are what replaced it. # tuwunel hard-fails to boot if `/etc/resolv.conf` has no # `nameserver` line (`Failed to configure DNS resolver ... no @@ -527,6 +535,42 @@ in systemd.services.tuwunel.serviceConfig.LoadCredential = [ "registration_token:${toString cfg.registrationTokenFile}" ]; + + # Federation TLS against a peer whose cert chains to the swarm + # root: tuwunel's outbound client is reqwest with the `rustls` + # feature, which builds a `rustls_platform_verifier::Verifier` + # and — because tuwunel calls `tls_certs_merge` rather than + # `tls_certs_only` — keeps the platform roots alongside its + # compiled-in webpki set. On Linux that verifier resolves through + # `rustls-native-certs` → `openssl-probe`, which reads + # `SSL_CERT_FILE`. So the openssl-shaped variable IS the lever + # here, despite tuwunel linking no openssl. + # + # ⚠️ CONCATENATE, never point at the anchor alone. `openssl-probe` + # uses `SSL_CERT_FILE` *instead of* the default location, so + # naming just the hive bundle would drop every public CA and + # break federation with the wider matrix network — trading a + # small outage for a much larger one. + systemd.services.hive-matrix-ca-bundle = lib.mkIf useSelfSigned { + description = "assemble tuwunel TLS trust bundle (system CAs + hive CA)"; + wantedBy = [ "tuwunel.service" ]; + before = [ "tuwunel.service" ]; + serviceConfig = { + Type = "oneshot"; + RemainAfterExit = true; + SyslogIdentifier = "hive-matrix-ca-bundle"; + }; + path = [ pkgs.coreutils ]; + script = '' + set -euo pipefail + install -d -m 0755 /run/hive-matrix-ca + cat /etc/ssl/certs/ca-certificates.crt ${caTrust.caContainerPath} \ + > ${matrixCaBundle} + chmod 0644 ${matrixCaBundle} + ''; + }; + systemd.services.tuwunel.environment.SSL_CERT_FILE = lib.mkIf useSelfSigned matrixCaBundle; + environment.systemPackages = [ cfg.package ]; }; }; @@ -550,8 +594,13 @@ in # gateway always runs alongside hyperhive, so the gateway container # unit always exists here. (Declarative `containers.` → # `container@.service` — the nspawn template NixOS generates.) - systemd.services."container@hive-matrix".after = [ - "container@hive-gateway.service" + # `mkMerge`, not a bare assignment: `caTrust.containerOrdering` also + # sets `after`/`requires` (so the bound trust bundle exists before + # nspawn wires the mount up), and two plain assignments to the same + # unit would conflict rather than combine. + systemd.services."container@hive-matrix" = lib.mkMerge [ + { after = [ "container@hive-gateway.service" ]; } + caTrust.containerOrdering ]; }; }