diff --git a/hive-c0re/src/paths.rs b/hive-c0re/src/paths.rs index 88eb2366..759f7eae 100644 --- a/hive-c0re/src/paths.rs +++ b/hive-c0re/src/paths.rs @@ -255,6 +255,9 @@ pub const FORGE_CORE_TOKEN: &str = "/var/lib/hyperhive/forge-core-token"; /// `matrix-register-token` — shared matrix registration token. // nix: bind-mounted into the tuwunel/matrix container (hive-matrix.nix) — must match. +// Not operator-option-driven: `registrationTokenFile` is `internal` on the nix +// side, and an `assertions` entry there rejects any attempt to move it, so this +// literal can never diverge from it. #[must_use] pub fn matrix_register_token() -> PathBuf { state_root().join("matrix-register-token") diff --git a/nix/host-modules/hive-matrix.nix b/nix/host-modules/hive-matrix.nix index fe6e39e0..1438b020 100644 --- a/nix/host-modules/hive-matrix.nix +++ b/nix/host-modules/hive-matrix.nix @@ -40,6 +40,12 @@ let # login into an intermittent one. matrixSecretPath = "/var/lib/tuwunel-oidc/${cfg.sso.clientId}.secret"; + # Single source for the registration-token path: the option's `default`, + # its `config`-level `mkDefault` self-definition, and the assertion that + # rejects a moved path all read this same binding rather than repeating + # the string literal. See `registrationTokenFile`'s own comment below. + registrationTokenPath = "/var/lib/hyperhive/matrix-register-token"; + # ⚠️ tuwunel does NOT read the path above directly, and this indirection # is not ceremony. Upstream's own words: "under systemd the path must be # visible to the service after sandboxing (ReadWritePaths / ProtectHome), @@ -452,7 +458,8 @@ in registrationTokenFile = lib.mkOption { type = lib.types.path; - default = "/var/lib/hyperhive/matrix-register-token"; + internal = true; + default = registrationTokenPath; description = '' Host path to a file containing the matrix registration token tuwunel reads to authorise new-account creation. The token is @@ -461,8 +468,28 @@ in tuwunel container at the same path. Agents never see this token — hive-c0re uses it to provision per-agent accounts and the agent only receives the resulting `access_token`. - Override only when integrating with externally-managed - registration tokens. + + Not operator-settable — `hive-c0re`'s Rust side derives this + same path independently (`paths::matrix_register_token()`) with + nothing wiring an override across, so a moved path used to + desync the two silently. An externally-managed token is + delivered by writing into *this* fixed path instead of moving it — + see `glue-matrix-bao-token.nix`, which fetches from the swarm + secret store and overwrites this file in place. + + Enforced by an `assertions` entry below rather than `readOnly`: + the `config` block gives this option its own `mkDefault` + definition (lowest priority) so any real override — an operator's, + or the pre-rename `swarm.matrix.registrationTokenFile` shim's — + still resolves cleanly instead of crashing eval with nixpkgs' + generic "read-only, set multiple times" message; the assertion + then names the actual problem. (`readOnly` was the first attempt — + atlas caught that it only rejects a *second* definition, + so with nothing else defining the option a lone override sailed + through silently, same desync as before with a lock that wasn't + locked. Fixed by defining the value here instead of leaving it on + `default` alone, but a proper `assertions` message beat re-adding + `readOnly` on top once the option had a real definition either way.) ''; }; @@ -507,6 +534,13 @@ in }; config = lib.mkIf deployCfg.matrix.enable { + # The option's own value, defined explicitly rather than left on its + # bare `default` — `mkDefault` so a real override (an operator's, or + # the pre-rename shim's) still resolves cleanly rather than crashing + # eval; the `assertions` entry below is what actually rejects it, with + # a message naming the reason. See that option's own comment above. + services.hyperhive.deploy.matrix.registrationTokenFile = lib.mkDefault registrationTokenPath; + # Matrix's own gateway surface: the sub-domain vhost, the name the # hive resolver answers for, and the Accept-header map that vhost's # SPA fallback reads. All three are matrix knowledge and none of @@ -676,6 +710,27 @@ in httpPort and has no such name. ''; } + { + # mkDefault above lets an override resolve instead of crashing + # eval — this is the actual rejection, with a message that names + # the real fix instead of nixpkgs' generic conflicting-definition + # text. Covers both the current path and the pre-rename + # `swarm.matrix.registrationTokenFile` shim in one check, since + # both land on the same merged option. + assertion = deployCfg.matrix.registrationTokenFile == registrationTokenPath; + message = '' + services.hyperhive.deploy.matrix.registrationTokenFile is fixed at + ${registrationTokenPath} and cannot be moved — hive-c0re's Rust + side derives this same path independently and has no way to learn + an override, so moving it desyncs the two silently instead of + loudly. + + Integrating an externally-managed registration token? Deliver it + by writing into ${registrationTokenPath} instead of pointing this + option elsewhere — see glue-matrix-bao-token.nix, which does + exactly that from the swarm secret store. + ''; + } ]; # One declaration, two readers. The homeserver knows its own callback diff --git a/nix/module-eval.nix b/nix/module-eval.nix index 29e23a1b..e61db7b4 100644 --- a/nix/module-eval.nix +++ b/nix/module-eval.nix @@ -296,6 +296,21 @@ let builtins.elem httpPort ports && matrixOldPath.containers.hive-matrix.bindMounts ? "/etc/matrix/register.token"; } + { + # registrationTokenFile lost its override capability entirely + # (bao-delivered secrets don't need one — glue-matrix-bao-token.nix + # already writes into the fixed path instead of moving it), unlike its + # five siblings in the same rename. `matrixOldPath` above proves the + # override still *resolves* (mkDefault, not a crash) — this proves it + # also gets *rejected*, by a named assertion rather than nixpkgs' generic + # conflicting-definition text. Reads `.assertions` directly (cheap: a + # list of `{assertion; message;}`, not `system.build.toplevel`) rather + # than forcing a real build just to observe a boolean. + name = "overriding registrationTokenFile (even via the pre-rename shim) trips a named assertion, not a silent desync"; + ok = lib.any ( + a: !a.assertion && lib.hasInfix "registrationTokenFile" a.message + ) matrixOldPath.assertions; + } { # Reads the DELIVERY UNIT, not the options: `responderConfigured` gates # whether it exists at all, and the seed path is interpolated into its