From d3f2d246e3ea1324e9d979b5b007eba5d16432e6 Mon Sep 17 00:00:00 2001 From: atlas Date: Mon, 3 Aug 2026 20:09:56 +0200 Subject: [PATCH] fix(#2860): hive-c0re stops assuming matrix is on localhost MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `MATRIX_HTTP` was `http://localhost:8008`, compiled in, used at 18 call sites. That address is right only while the homeserver happens to share this daemon's netns, and its doc comment asserted exactly that as a general fact. A hive whose homeserver lives anywhere else builds fine and then talks to the wrong machine. It now reads `HIVE_MATRIX_API_URL`, which `hive-c0re.nix` sets from `hyperhive.matrix.apiUrl`. The matrix module fills that in with its own loopback listener when it is the thing running tuwunel — there it is not a guess but a fact about what it just started — and the operator sets it by hand otherwise. There is no compiled-in fallback, for the same reason `forge_http_base()` has none. `is_present()` follows. It used to scan `nixos-container list` for `hive-matrix`, which answers "is the homeserver a container on this host" — a different question, and the reason a remote homeserver would silently no-op no matter how it was addressed. It now asks whether a URL is configured. A co-located hive is unaffected: the module supplies the loopback URL whenever it runs tuwunel itself. It also stops being `async`, since it no longer does IO, and `require_matrix_present`'s message names both ways to have a homeserver rather than only the local container. Absent a URL, every matrix path no-ops exactly as it did with no container, and the two accessors make that structural: `Option` for the callers that fall back to `None`, a `Result` flavour naming the skipped `is_present()` gate for the ones that propagate. --- hive-c0re/src/matrix.rs | 125 +++++++++++++++++++++++++--------------- hive-c0re/src/server.rs | 24 +++++--- 2 files changed, 92 insertions(+), 57 deletions(-) diff --git a/hive-c0re/src/matrix.rs b/hive-c0re/src/matrix.rs index 4f98ebc9..d724194c 100644 --- a/hive-c0re/src/matrix.rs +++ b/hive-c0re/src/matrix.rs @@ -14,19 +14,39 @@ use reqwest::StatusCode; use crate::coordinator::Coordinator; -/// nspawn container name for the matrix homeserver — mirrors -/// `hive-forge` and matches the bare-name allow-list the lifecycle -/// scanner skips over. -const MATRIX_CONTAINER: &str = "hive-matrix"; -/// Local-host URL of the tuwunel client-server API, for **this daemon -/// only**: hive-c0re and the homeserver share the host netns, so -/// `localhost:` reaches it from here. +/// Client-server API base this daemon provisions against, from +/// `HIVE_MATRIX_API_URL` (set by `hive-c0re.nix` from +/// `hyperhive.matrix.apiUrl`). /// -/// Deliberately not an agent-facing address. An agent has its own netns, -/// where `localhost` is the agent — agents are handed the gateway vhost -/// (`matrix.`) via `HIVE_MATRIX_URL` instead, and get nothing at -/// all when the hive has no vhost to offer. -const MATRIX_HTTP: &str = "http://localhost:8008"; +/// `None` means **this hive has no homeserver to provision against** and +/// every matrix path no-ops — see [`is_present`]. There is deliberately no +/// fallback: `localhost:8008` is right only when the homeserver happens to +/// share this daemon's netns, and an address baked into the binary is one +/// that builds fine and then talks to the wrong machine. The nix module +/// supplies the loopback address when it is itself the thing running +/// tuwunel, where it is not a guess but a fact about what it just started. +/// +/// Also not an agent-facing address either way. An agent has its own netns; +/// agents are handed the gateway vhost via `HIVE_MATRIX_URL`, and get +/// nothing at all when the hive has no vhost to offer. +fn matrix_http() -> Option<&'static str> { + static BASE: std::sync::OnceLock> = std::sync::OnceLock::new(); + BASE.get_or_init(|| std::env::var("HIVE_MATRIX_API_URL").ok()) + .as_deref() +} + +/// [`matrix_http`] for the call sites that propagate with `?`. +/// +/// # Errors +/// When no homeserver is configured. Reaching one of these paths at all +/// means an [`is_present`] gate was skipped, so the message names that +/// rather than the missing variable. +fn matrix_base() -> Result<&'static str> { + matrix_http().context( + "matrix: no homeserver configured (hyperhive.matrix.apiUrl / HIVE_MATRIX_API_URL) — \ + this path should have been gated on matrix::is_present()", + ) +} /// Length (bytes) of the random registration token. 32 raw bytes ⇒ /// 64-char hex string; comfortable for a long-lived shared secret. const REGISTER_TOKEN_BYTES: usize = 32; @@ -112,15 +132,17 @@ pub fn hive_chat_room_id_path() -> PathBuf { crate::paths::matrix_chat_room_id() } -/// Probe whether `hive-matrix` exists as a nixos-container. Cheap — -/// `nixos-container list` is just a directory scan in /etc. Same shape -/// as `forge::is_present` — routed through hive-priv since -/// `nixos-container` needs root and hive-c0re runs unprivileged. -pub async fn is_present() -> bool { - let Ok(stdout) = crate::priv_client::list_containers().await else { - return false; - }; - stdout.lines().any(|l| l.trim() == MATRIX_CONTAINER) +/// Whether this hive has a homeserver to provision against. +/// +/// **A configured API URL, not a local container.** It used to scan +/// `nixos-container list` for `hive-matrix`, which answers a different +/// question — "is the homeserver a container on this host" — and so made a +/// remote homeserver silently no-op no matter how it was addressed. The +/// nix module still supplies the loopback URL whenever it runs tuwunel +/// itself, so a co-located hive behaves exactly as before. +#[must_use] +pub fn is_present() -> bool { + matrix_http().is_some() } /// Read `n` cryptographic-quality bytes from `/dev/urandom` and return @@ -183,7 +205,8 @@ async fn register_post( client: &reqwest::Client, body: &serde_json::Value, ) -> Result<(StatusCode, serde_json::Value)> { - let url = format!("{MATRIX_HTTP}/_matrix/client/v3/register"); + let base = matrix_base()?; + let url = format!("{base}/_matrix/client/v3/register"); let resp = client .post(&url) .json(body) @@ -280,7 +303,8 @@ fn extract_access_token(body: &serde_json::Value) -> Result { /// in which case manual recovery via `hivectl matrix create-user` is /// required. async fn login_user(client: &reqwest::Client, agent: &str, password: &str) -> Result { - let url = format!("{MATRIX_HTTP}/_matrix/client/v3/login"); + let base = matrix_base()?; + let url = format!("{base}/_matrix/client/v3/login"); let body = serde_json::json!({ "type": "m.login.password", "identifier": { @@ -345,9 +369,10 @@ async fn discover_admin_room_id( admin_token: &str, server_name: &str, ) -> Result { + let base = matrix_base()?; // #admins:server → %23admins%3A let encoded_alias = format!("%23admins%3A{server_name}"); - let url = format!("{MATRIX_HTTP}/_matrix/client/v3/directory/room/{encoded_alias}"); + let url = format!("{base}/_matrix/client/v3/directory/room/{encoded_alias}"); let resp = client .get(&url) .bearer_auth(admin_token) @@ -484,10 +509,11 @@ async fn admin_room_send_and_poll( command: &str, check: impl Fn(&str) -> Option, ) -> Result { + let base = matrix_base()?; // Send the command; record the event_id so we can use it as an anchor. let txn_id = random_hex(8)?; let send_url = - format!("{MATRIX_HTTP}/_matrix/client/v3/rooms/{room_url}/send/m.room.message/{txn_id}"); + format!("{base}/_matrix/client/v3/rooms/{room_url}/send/m.room.message/{txn_id}"); let send_resp = client .put(&send_url) .bearer_auth(admin_token) @@ -512,8 +538,7 @@ async fn admin_room_send_and_poll( // on each tick. Walk the list until we hit our own command event_id; // everything *before* that marker arrived after our command. let own_user_id = format!("@{HIVE_ADMIN_LOCALPART}:{server_name}"); - let poll_url = - format!("{MATRIX_HTTP}/_matrix/client/v3/rooms/{room_url}/messages?dir=b&limit=20"); + let poll_url = format!("{base}/_matrix/client/v3/rooms/{room_url}/messages?dir=b&limit=20"); for _ in 0..15_u8 { tokio::time::sleep(std::time::Duration::from_secs(1)).await; let poll_json = client @@ -768,7 +793,7 @@ pub async fn sync_agent(client: &reqwest::Client, name: &str, register_token: &s /// setup in [`ensure_all`] so the rebuild path and the startup sweep /// stay equivalent. No-op when the matrix container is absent. pub async fn sync_agent_standalone(name: &str) { - if !is_present().await { + if !is_present() { return; } let register_token = match ensure_register_token() { @@ -938,7 +963,8 @@ fn persist_password(localpart: &str, password: &str) { /// `GET /_matrix/key/v2/server` (unauthenticated federation key endpoint). /// The response JSON always includes `"server_name"` per the matrix spec. pub async fn discover_server_name(client: &reqwest::Client) -> Result { - let url = format!("{MATRIX_HTTP}/_matrix/key/v2/server"); + let base = matrix_base()?; + let url = format!("{base}/_matrix/key/v2/server"); let resp = client .get(&url) .send() @@ -1000,7 +1026,8 @@ fn persist_space_room_id(room_id: &str) -> Result<()> { /// Name-based (not alias-based) rediscovery keeps the Space free of any /// special-char room alias — the hardcoded plain name is the anchor. async fn find_space_by_name(client: &reqwest::Client, admin_token: &str) -> Option { - let joined_url = format!("{MATRIX_HTTP}/_matrix/client/v3/joined_rooms"); + let base = matrix_http()?; + let joined_url = format!("{base}/_matrix/client/v3/joined_rooms"); let joined: serde_json::Value = client .get(&joined_url) .bearer_auth(admin_token) @@ -1017,8 +1044,7 @@ async fn find_space_by_name(client: &reqwest::Client, admin_token: &str) -> Opti }; let encoded = encode_room_id_for_url(room_id); // Must be an m.space (m.room.create `type`). - let create_url = - format!("{MATRIX_HTTP}/_matrix/client/v3/rooms/{encoded}/state/m.room.create/"); + let create_url = format!("{base}/_matrix/client/v3/rooms/{encoded}/state/m.room.create/"); let is_space = match client .get(&create_url) .bearer_auth(admin_token) @@ -1036,8 +1062,7 @@ async fn find_space_by_name(client: &reqwest::Client, admin_token: &str) -> Opti continue; } // …and named HIVE_SPACE_NAME (m.room.name `name`). - let name_url = - format!("{MATRIX_HTTP}/_matrix/client/v3/rooms/{encoded}/state/m.room.name/"); + let name_url = format!("{base}/_matrix/client/v3/rooms/{encoded}/state/m.room.name/"); let name_matches = match client.get(&name_url).bearer_auth(admin_token).send().await { Ok(r) if r.status().is_success() => r .json::() @@ -1070,6 +1095,7 @@ async fn find_space_by_name(client: &reqwest::Client, admin_token: &str) -> Opti /// Returns an error if the homeserver is unreachable, `createRoom` fails, /// or the room-ID file cannot be written. pub async fn ensure_hive_space(client: &reqwest::Client, admin_token: &str) -> Result { + let base = matrix_base()?; // 1. Stored room id wins (fast path). if let Ok(existing) = std::fs::read_to_string(hive_space_room_id_path()) { let trimmed = existing.trim().to_owned(); @@ -1088,7 +1114,7 @@ pub async fn ensure_hive_space(client: &reqwest::Client, admin_token: &str) -> R } // 3. Create the space (plain hardcoded name, no alias). - let url = format!("{MATRIX_HTTP}/_matrix/client/v3/createRoom"); + let url = format!("{base}/_matrix/client/v3/createRoom"); let body = serde_json::json!({ "name": HIVE_SPACE_NAME, "creation_content": { "type": "m.space" }, @@ -1138,7 +1164,8 @@ fn persist_chat_room_id(room_id: &str) -> Result<()> { /// room instead of spawning a duplicate. `None` if the homeserver is /// unreachable or no match exists. async fn find_chat_room_by_name(client: &reqwest::Client, admin_token: &str) -> Option { - let joined_url = format!("{MATRIX_HTTP}/_matrix/client/v3/joined_rooms"); + let base = matrix_http()?; + let joined_url = format!("{base}/_matrix/client/v3/joined_rooms"); let joined: serde_json::Value = client .get(&joined_url) .bearer_auth(admin_token) @@ -1155,8 +1182,7 @@ async fn find_chat_room_by_name(client: &reqwest::Client, admin_token: &str) -> }; let encoded = encode_room_id_for_url(room_id); // Skip the Space itself (and any other m.space). - let create_url = - format!("{MATRIX_HTTP}/_matrix/client/v3/rooms/{encoded}/state/m.room.create/"); + let create_url = format!("{base}/_matrix/client/v3/rooms/{encoded}/state/m.room.create/"); let is_space = match client .get(&create_url) .bearer_auth(admin_token) @@ -1174,8 +1200,7 @@ async fn find_chat_room_by_name(client: &reqwest::Client, admin_token: &str) -> continue; } // …and named HIVE_CHAT_ROOM_NAME (m.room.name `name`). - let name_url = - format!("{MATRIX_HTTP}/_matrix/client/v3/rooms/{encoded}/state/m.room.name/"); + let name_url = format!("{base}/_matrix/client/v3/rooms/{encoded}/state/m.room.name/"); let name_matches = match client.get(&name_url).bearer_auth(admin_token).send().await { Ok(r) if r.status().is_success() => r .json::() @@ -1201,11 +1226,11 @@ async fn set_room_state( state_key: &str, content: &serde_json::Value, ) -> Result<()> { + let base = matrix_base()?; let encoded_room = encode_room_id_for_url(room_id); let encoded_key = encode_room_id_for_url(state_key); - let url = format!( - "{MATRIX_HTTP}/_matrix/client/v3/rooms/{encoded_room}/state/{event_type}/{encoded_key}" - ); + let url = + format!("{base}/_matrix/client/v3/rooms/{encoded_room}/state/{event_type}/{encoded_key}"); let resp = client .put(&url) .bearer_auth(admin_token) @@ -1248,6 +1273,7 @@ pub async fn ensure_hive_chat_room( space_room_id: &str, server_name: &str, ) -> Result { + let base = matrix_base()?; // 1. Stored room id wins (fast path). 2. Rediscover by name before // creating (prevents duplicates after a state wipe). 3. Create. let room_id = if let Some(id) = std::fs::read_to_string(hive_chat_room_id_path()) @@ -1264,7 +1290,7 @@ pub async fn ensure_hive_chat_room( } else { // `initial_state` is applied after the preset-derived state, so the // restricted join rule overrides private_chat's invite-only default. - let url = format!("{MATRIX_HTTP}/_matrix/client/v3/createRoom"); + let url = format!("{base}/_matrix/client/v3/createRoom"); let body = serde_json::json!({ "name": HIVE_CHAT_ROOM_NAME, "topic": HIVE_CHAT_ROOM_TOPIC, @@ -1364,11 +1390,12 @@ async fn room_membership( encoded_room_id: &str, user_id: &str, ) -> Option { + let base = matrix_http()?; // `:` must be percent-encoded in both the room-id and user-id path // segments; `@` and `!` are permitted path characters per RFC 3986. let encoded_user = user_id.replace(':', "%3A"); let url = format!( - "{MATRIX_HTTP}/_matrix/client/v3/rooms/{encoded_room_id}/state/m.room.member/{encoded_user}" + "{base}/_matrix/client/v3/rooms/{encoded_room_id}/state/m.room.member/{encoded_user}" ); let resp = client .get(&url) @@ -1396,6 +1423,7 @@ async fn invite_user_id( room_id: &str, user_id: &str, ) -> Result<()> { + let base = matrix_base()?; // `:` must be percent-encoded in the room-id path segment; `!` is // permitted in URL path characters per RFC 3986. let encoded_room_id = room_id.replace(':', "%3A"); @@ -1410,7 +1438,7 @@ async fn invite_user_id( return Ok(()); } - let url = format!("{MATRIX_HTTP}/_matrix/client/v3/rooms/{encoded_room_id}/invite"); + let url = format!("{base}/_matrix/client/v3/rooms/{encoded_room_id}/invite"); let resp = client .post(&url) .bearer_auth(admin_token) @@ -1490,8 +1518,9 @@ async fn resolve_room_alias( admin_token: &str, alias: &str, ) -> Result { + let base = matrix_base()?; let encoded = alias.replace('#', "%23").replace(':', "%3A"); - let url = format!("{MATRIX_HTTP}/_matrix/client/v3/directory/room/{encoded}"); + let url = format!("{base}/_matrix/client/v3/directory/room/{encoded}"); let resp = client .get(&url) .bearer_auth(admin_token) @@ -1522,7 +1551,7 @@ async fn resolve_room_alias( /// dashboard banner on persistent failure (this sweep re-runs every 30 /// minutes, so a one-off blip self-heals without ever bannering). pub async fn ensure_all() -> bool { - if !is_present().await { + if !is_present() { tracing::debug!("matrix: hive-matrix container absent, skipping user sweep"); return true; } diff --git a/hive-c0re/src/server.rs b/hive-c0re/src/server.rs index b9caf19f..26803aa2 100644 --- a/hive-c0re/src/server.rs +++ b/hive-c0re/src/server.rs @@ -463,13 +463,19 @@ async fn handle_set_resource_limits( )])) } -/// Guard: matrix provisioning needs the homeserver container running. -async fn require_matrix_present() -> Result<()> { - if crate::matrix::is_present().await { +/// Guard: matrix provisioning needs a homeserver to provision against. +/// +/// Answers "is one configured", not "is one running here" — the message names +/// both ways to get there, since a hive that talks to someone else's +/// homeserver never enables the local container at all. +fn require_matrix_present() -> Result<()> { + if crate::matrix::is_present() { return Ok(()); } anyhow::bail!( - "hive-matrix container not running — start it (services.hyperhive.matrix.enable = true) before provisioning matrix users" + "no matrix homeserver configured — set services.hyperhive.matrix.enable = true to run one \ + here, or services.hyperhive.matrix.apiUrl to point at an existing one, before \ + provisioning matrix users" ) } @@ -477,7 +483,7 @@ async fn handle_matrix_create_user( name: &hive_types::Ident, password: Option<&str>, ) -> Result { - require_matrix_present().await?; + require_matrix_present()?; let register_token = crate::matrix::ensure_register_token().context("read matrix register token")?; let client = matrix_http_client()?; @@ -689,7 +695,7 @@ async fn handle_push_snapshot( } async fn handle_matrix_sync_admin() -> Result { - require_matrix_present().await?; + require_matrix_present()?; let register_token = crate::matrix::ensure_register_token().context("read matrix register token")?; let client = matrix_http_client()?; @@ -707,7 +713,7 @@ async fn handle_matrix_sync_admin() -> Result { } async fn handle_matrix_promote_user(name: &str) -> Result { - require_matrix_present().await?; + require_matrix_present()?; let admin_token = crate::matrix::read_admin_token()?; let client = matrix_http_client()?; let server_name = crate::matrix::discover_server_name(&client) @@ -722,7 +728,7 @@ async fn handle_matrix_promote_user(name: &str) -> Result { } async fn handle_matrix_invite(user: &str, room: Option<&str>) -> Result { - require_matrix_present().await?; + require_matrix_present()?; let admin_token = crate::matrix::read_admin_token()?; let client = matrix_http_client()?; let server_name = crate::matrix::discover_server_name(&client) @@ -742,7 +748,7 @@ async fn handle_matrix_invite(user: &str, room: Option<&str>) -> Result Result { - require_matrix_present().await?; + require_matrix_present()?; let admin_token = crate::matrix::read_admin_token()?; let client = matrix_http_client()?; let server_name = crate::matrix::discover_server_name(&client)