diff --git a/nix/host-modules/default.nix b/nix/host-modules/default.nix index bf7c2fa0..0ff92c8e 100644 --- a/nix/host-modules/default.nix +++ b/nix/host-modules/default.nix @@ -24,6 +24,7 @@ ./hive-tls.nix ./otel.nix ./glue-bao-tls.nix + ./glue-matrix-bao-token.nix ./swarm-authelia.nix ./swarm-bao.nix ./swarm-ca.nix diff --git a/nix/host-modules/glue-matrix-bao-token.nix b/nix/host-modules/glue-matrix-bao-token.nix new file mode 100644 index 00000000..a89db5b3 --- /dev/null +++ b/nix/host-modules/glue-matrix-bao-token.nix @@ -0,0 +1,103 @@ +# Glue: the matrix registration token comes from the secret store. +# +# The store's first reader, and deliberately a small one. It fetches an opaque +# 32-byte value and writes it where ./hive-matrix.nix already looks — the +# homeserver never learns the store exists, and its config is unchanged. +# +# ⚠️ Why this credential first. It has no second file and no format: authelia's +# OIDC secret needs a `.secret` *and* a matching `.digest`, so shipping that +# one first would debug "can a reader authenticate and get bytes back" and +# "did we write authelia's file format right" at the same time, with an SSO +# outage as the failure mode. Here the failure is narrow — new agent accounts +# cannot be provisioned, existing ones are untouched, nothing crash-loops. +# +# ⚠️ The fallback is today's behaviour, not a new one. `hive-matrix.nix`'s +# activation script still mints a token when the file is absent; this unit +# overwrites it with the swarm's copy when the store has one. A store that is +# empty or unreachable leaves a working hive with a local token. +# +# 📌 LIMIT, stated rather than hidden: this gates on the store running HERE. +# A hive reading from a store on another machine needs the same unit with that +# machine's address and a client leaf issued out of band — the mechanism is +# identical, only `-address` and the cert's provenance differ. Deferred until +# there is a second hive to test it against, rather than shipped untested. +{ + pkgs, + lib, + config, + ... +}: +let + hyperhiveCfg = config.services.hyperhive; + deployCfg = hyperhiveCfg.deploy; + baoCfg = hyperhiveCfg.swarm.bao; + matrixCfg = hyperhiveCfg.swarm.matrix; + + # Owned by ./glue-bao-tls.nix, which mints them. Named here rather than + # shared through a `let`: a cross-module binding would make these two files + # one file with a gap in the middle, and the whole point of a glue module is + # that it can be deleted on its own. + pkiDir = "/var/lib/swarm-bao-pki"; + + # Where the token lives in the store. A path, not a convention to guess at: + # whoever writes it and whoever reads it must agree, and the agreement + # belongs in one visible place. + tokenPath = "secret/swarm/matrix/registration-token"; + + # A literal, not an option — ./hive-matrix.nix names its container + # `containers.hive-matrix` directly and declares no `machine` to derive it + # from, which the trust-bundle call in that file already says out loud. + # ⚠️ `matrixCfg.machine` parses fine and fails at module-system resolution, + # so this is the kind of mistake only reading the target module catches. + matrixMachine = "hive-matrix"; +in +{ + config = lib.mkIf (hyperhiveCfg.enable && deployCfg.bao.enable && deployCfg.matrix.enable) { + systemd.services.swarm-bao-matrix-token = { + description = "fetch the matrix registration token from the swarm secret store"; + after = [ + "swarm-bao-pki.service" + "container@${baoCfg.machine}.service" + ]; + wants = [ "container@${baoCfg.machine}.service" ]; + requires = [ "swarm-bao-pki.service" ]; + before = [ "container@${matrixMachine}.service" ]; + wantedBy = [ "container@${matrixMachine}.service" ]; + path = [ + deployCfg.bao.package + pkgs.coreutils + ]; + serviceConfig = { + Type = "oneshot"; + RemainAfterExit = true; + }; + environment = { + BAO_ADDR = "https://${baoCfg.domain}:${toString baoCfg.port}"; + BAO_CACERT = "${pkiDir}/ca.pem"; + BAO_CLIENT_CERT = "${pkiDir}/client.pem"; + BAO_CLIENT_KEY = "${pkiDir}/client-key.pem"; + }; + script = '' + set -euo pipefail + + # A sealed or uninitialised store answers on the port and times out on + # every read, so "the store is up" is not the same as "the store can + # answer" -- bound the wait rather than hanging the boot behind it. + if ! token="$(bao kv get -field=value ${lib.escapeShellArg tokenPath} 2>/dev/null)"; then + echo "swarm-bao holds no ${tokenPath}, or is sealed/unreachable." >&2 + echo "Keeping the token hive-matrix already has." >&2 + exit 0 + fi + + if [ -z "$token" ]; then + echo "swarm-bao returned an empty ${tokenPath}; keeping the local token." >&2 + exit 0 + fi + + umask 077 + printf '%s\n' "$token" > ${lib.escapeShellArg (toString matrixCfg.registrationTokenFile)} + chmod 0600 ${lib.escapeShellArg (toString matrixCfg.registrationTokenFile)} + ''; + }; + }; +}