//! Optional matrix-tuwunel wiring: shared registration token (host) + //! per-agent UIAA registration → `/matrix-token`. No-op //! when the `hive-matrix` container isn't running, so operators who //! haven't flipped `hyperhive.matrix.enable = true` pay nothing. //! //! See `docs/matrix.md::Provisioning flow (registration token)` for //! the full UIAA round-trip, token-file shape, and host/container //! bind-mount layout. use std::path::PathBuf; use anyhow::{Context, Result}; 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. Shares the host /// netns so `localhost:` resolves both from the daemon and from /// inside any sub-agent container. const MATRIX_HTTP: &str = "http://localhost:8008"; /// 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; /// HTTP timeout for registration round-trips. UIAA is two POSTs; even /// the slow path should finish well inside this budget. const HTTP_TIMEOUT_SECS: u64 = 10; /// Length (bytes) of the throwaway per-agent matrix password. Random /// 32-byte hex — agents never log in with the password (they /// authenticate by `access_token`), so it's protocol overhead. We /// store it nowhere. const PASSWORD_BYTES: usize = 32; /// Matrix localpart for the hive system admin account. Registered /// before any agent account in [`ensure_all`] so it becomes the first /// user on the homeserver — Conduit/tuwunel grants admin rights to the /// first registered user automatically. Not an agent; has no state dir. pub const HIVE_ADMIN_LOCALPART: &str = "hive"; /// Display name of the hive Space. Plain text, no special characters, so /// the Space stays rediscoverable by name (no room alias needed) even when /// the persisted room-id file is lost — preventing duplicate spaces from /// being created on the next sweep. pub const HIVE_SPACE_NAME: &str = "hive"; /// Display name of the default "hive chat" room — the `m.space.child` of /// the hive Space that every agent + the operator can join. Plain text so /// it stays rediscoverable by name (mirrors [`HIVE_SPACE_NAME`]) when the /// persisted room-id file is lost, preventing duplicate chat rooms. pub const HIVE_CHAT_ROOM_NAME: &str = "hive-chat"; /// Topic for the default hive chat room. const HIVE_CHAT_ROOM_TOPIC: &str = "Hive-wide chat for all agents and the operator. Auto-provisioned by hive-c0re."; /// Host path for the hive admin matrix access token. Outside every /// purgeable path — not deleted by `destroy --purge` on any agent. #[must_use] pub fn admin_token_path() -> PathBuf { crate::paths::matrix_admin_token() } /// Token file inside the agent's bind-mounted state dir (visible as /// `/state/matrix-token` from inside the container). fn token_path(name: &hive_types::Ident) -> PathBuf { Coordinator::agent_notes_dir(name).join("matrix-token") } /// Password file for the agent's matrix account. Stored OUTSIDE the /// purgeable `agent_state_root` tree so it survives `destroy --purge` /// and allows re-login recovery when the same agent name is re-spawned. /// /// Path: `/var/lib/hyperhive/matrix/creds/-password` /// /// The token file lives inside the agent's bind-mounted state dir (under /// `agent_notes_dir`) so the agent container can read it; the password /// file is host-side only (agents never log in by password — they use /// the access token exclusively) and belongs with other hive-c0re /// credential state, not inside the purgeable per-agent tree. fn password_path(name: &str) -> PathBuf { crate::paths::matrix_creds_dir().join(format!("{name}-password")) } /// Legacy password path (inside the old purgeable `agent_notes_dir`). /// Used only during the one-time migration in [`ensure_user_for`] to /// move credentials from old deployments to the new location. Safe to /// call after `destroy --purge` — the path will simply not exist and /// the migration is a no-op. fn legacy_password_path(name: &hive_types::Ident) -> PathBuf { Coordinator::agent_notes_dir(name).join("matrix-password") } /// Host path where the hive Matrix Space room ID is persisted. /// Outside every purgeable path — not deleted by `destroy --purge`. #[must_use] pub fn hive_space_room_id_path() -> PathBuf { crate::paths::matrix_space_room_id() } /// Host path where the default hive chat room ID is persisted. Outside /// every purgeable path — not deleted by `destroy --purge`. #[must_use] 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) } /// Read `n` cryptographic-quality bytes from `/dev/urandom` and return /// them hex-encoded. Avoids pulling a workspace `rand` dep just for /// 32 bytes of randomness; the kernel's CSPRNG is more than enough for /// a long-lived shared secret on the same host. fn random_hex(n: usize) -> Result { use std::io::Read; let mut buf = vec![0_u8; n]; let mut f = std::fs::File::open("/dev/urandom").context("open /dev/urandom")?; f.read_exact(&mut buf).context("read /dev/urandom")?; let mut hex = String::with_capacity(n * 2); for b in &buf { use std::fmt::Write as _; write!(hex, "{b:02x}").ok(); } Ok(hex) } /// Ensure the registration token file exists; returns its contents. /// Generates a fresh 32-byte hex token on first call (mode 0600, /// root-only), then re-reads on subsequent calls. The hive-matrix /// nixos module bind-mounts this file read-only into the tuwunel /// container so the homeserver can authenticate registration requests /// against the same secret hive-c0re holds. pub fn ensure_register_token() -> Result { use std::os::unix::fs::PermissionsExt; let path = crate::paths::matrix_register_token(); if let Ok(existing) = std::fs::read_to_string(&path) { let trimmed = existing.trim().to_owned(); if !trimmed.is_empty() { return Ok(trimmed); } } let token = random_hex(REGISTER_TOKEN_BYTES)?; if let Some(parent) = path.parent() { std::fs::create_dir_all(parent).ok(); } std::fs::write(&path, format!("{token}\n")) .with_context(|| format!("write registration token to {}", path.display()))?; let _ = std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600)); tracing::info!(path = %path.display(), "matrix: generated registration token"); Ok(token) } /// Build the localpart of a matrix user id for `agent`. Matrix /// usernames are 1-255 chars from the `[a-z0-9._=-/]` alphabet; agent /// names already conform (hyperhive enforces a strict subset), so no /// escaping is needed at the boundary. fn user_localpart(agent: &str) -> &str { agent } /// Send a single registration POST and parse the response. Returns /// `Ok((status, body))` on any successful HTTP round-trip (including /// the expected 401 from the first UIAA leg); errors only on transport /// failure. Body is the parsed JSON value — UIAA passes session + /// flow state via JSON, never via headers. 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 resp = client .post(&url) .json(body) .send() .await .context("matrix: POST /register")?; let status = resp.status(); let json = resp .json::() .await .context("matrix: parse /register response")?; Ok((status, json)) } /// Generate a throwaway random password for matrix UIAA registration. /// `PASSWORD_BYTES` raw bytes ⇒ 64-char hex string. Agents authenticate /// by `access_token` so the password is protocol overhead we never /// persist; the operator path in `hivectl` lets the caller supply a /// real password instead so they can log into a matrix web client /// (`m.login.password`). pub fn random_password() -> Result { random_hex(PASSWORD_BYTES) } /// Run the matrix-spec UIAA flow to register `agent` with the given /// `password` and return the resulting access token. Two round-trips: /// first POST elicits the 401 + session id, second POST supplies the /// registration token in the `auth` block. If the homeserver returns /// 200 on the first POST (no flow stages required — `allow_registration` /// with no token), we take the access token directly. /// /// Caller picks the password: agents use [`random_password`] (throwaway /// — they auth by `access_token`), operators on the `hivectl` path /// supply their own so they can log into matrix web clients. async fn register_user( client: &reqwest::Client, agent: &str, register_token: &str, password: &str, ) -> Result { let localpart = user_localpart(agent); let initial = serde_json::json!({ "username": localpart, "password": password, // device_id stays stable across re-runs of ensure_user_for so // a re-mint doesn't strand orphan devices in tuwunel. "device_id": format!("hyperhive-{agent}"), "initial_device_display_name": format!("hyperhive ({agent})"), "inhibit_login": false, }); let (status, body) = register_post(client, &initial).await?; // Some servers accept the first POST when allow_registration is // open. Take the access_token + we're done. if status.is_success() { return extract_access_token(&body); } if status != StatusCode::UNAUTHORIZED { anyhow::bail!("matrix: /register first leg HTTP {status}, body: {body}"); } let session = body["session"] .as_str() .with_context(|| format!("matrix: missing UIAA session in 401, body: {body}"))?; let authed = serde_json::json!({ "username": localpart, "password": password, "device_id": format!("hyperhive-{agent}"), "initial_device_display_name": format!("hyperhive ({agent})"), "inhibit_login": false, "auth": { "type": "m.login.registration_token", "token": register_token, "session": session, }, }); let (status, body) = register_post(client, &authed).await?; if !status.is_success() { anyhow::bail!("matrix: /register auth leg HTTP {status}, body: {body}"); } extract_access_token(&body) } /// Pull `access_token` out of a successful /register response. fn extract_access_token(body: &serde_json::Value) -> Result { body["access_token"] .as_str() .map(str::to_owned) .with_context(|| format!("matrix: missing access_token in response: {body}")) } /// Login with `m.login.password` and return the access token. Fallback /// for when registration fails with `M_USER_IN_USE` — the account /// already exists in the homeserver but the token file was lost. Fails /// if the stored password no longer matches (e.g. homeserver wiped), /// 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 body = serde_json::json!({ "type": "m.login.password", "identifier": { "type": "m.id.user", "user": user_localpart(agent), }, "password": password, "device_id": format!("hyperhive-{agent}"), "initial_device_display_name": format!("hyperhive ({agent})"), }); let resp = client .post(&url) .json(&body) .send() .await .context("matrix: POST /login")?; let status = resp.status(); let json = resp .json::() .await .context("matrix: parse /login response")?; if !status.is_success() { anyhow::bail!("matrix: /login HTTP {status} for agent {agent}, body: {json}"); } extract_access_token(&json) } /// Auto-recovery helper: reset a user's matrix password via the admin API /// when the locally stored password is missing. Requires a valid hive admin /// token at [`admin_token_path()`]. Returns the new password (already /// persisted to [`password_path(name)`]) on success. /// /// Called by [`ensure_user_for`] when registration returns `M_USER_IN_USE` /// but the password file is absent — covers the case where agent state dirs /// were wiped but the homeserver still has the accounts. async fn auto_reset_password(client: &reqwest::Client, name: &str) -> anyhow::Result { let admin_token = read_admin_token() .context("matrix: admin token unavailable for auto-recovery; provision hive admin first")?; let server_name = discover_server_name(client) .await .context("matrix: discover_server_name for auto-recovery")?; let effective_password = reset_user_password(client, &admin_token, name, &server_name) .await .with_context(|| format!("matrix: admin-room password reset for {name} (auto-recovery)"))?; tracing::info!(%name, "matrix: auto-recovered password via admin-room reset"); Ok(effective_password) } // --------------------------------------------------------------------------- // Admin-room fallback for password reset // --------------------------------------------------------------------------- /// Percent-encode a matrix room ID for use in a URL path segment. /// Only `:` needs encoding; `!` and alphanumerics are path-safe. fn encode_room_id_for_url(room_id: &str) -> String { room_id.replace(':', "%3A") } /// Look up the room ID for the `#admins:` alias. async fn discover_admin_room_id( client: &reqwest::Client, admin_token: &str, server_name: &str, ) -> Result { // #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 resp = client .get(&url) .bearer_auth(admin_token) .send() .await .context("matrix: GET admin room alias")?; let status = resp.status(); let json = resp .json::() .await .context("matrix: parse admin room alias response")?; if !status.is_success() { anyhow::bail!("matrix: admin room alias lookup failed: HTTP {status}, body: {json}"); } json["room_id"] .as_str() .map(ToString::to_string) .ok_or_else(|| anyhow::anyhow!("matrix: admin room alias response missing room_id: {json}")) } /// Extract the new password from a conduit/tuwunel admin-room reset reply. /// /// The admin bot always renders the new password as a backtick code span. /// The live reply observed in the `#admins` room is: /// "Successfully reset the password for user @x:server: ``" /// The surrounding prose varies between builds (the delimiter is `: ` after /// the user id, not `" to:"`), so we anchor on the code span rather than /// parsing the prose. Returns the content of the first backtick pair when the /// message is a password-reset success. /// /// Guard: an error reply can also code-span the *user id* ("@x:server"); a /// real password has no whitespace and isn't a `@localpart:server` id, so we /// reject that shape and return `None`. On `None` the caller surfaces the /// timeout and `admin_room_send_and_poll` logs the unparsed body — so a /// future format change is visible rather than silently mis-parsed. fn extract_new_password(bot_message: &str) -> Option { // Only consider password-reset success replies. if !bot_message.to_ascii_lowercase().contains("password") { return None; } // Content of the first backtick code span. let open = bot_message.find('`')?; let after = &bot_message[open + 1..]; let close = after.find('`')?; let pw = &after[..close]; // Reject a code-spanned matrix user id from an error reply, and any // multi-token span — generated passwords are a single whitespace-free run. if pw.is_empty() || pw.contains(char::is_whitespace) || (pw.starts_with('@') && pw.contains(':')) { return None; } Some(pw.to_owned()) } #[cfg(test)] mod extract_new_password_tests { use super::extract_new_password; #[test] fn conduit_live_admin_room_format() { // The exact reply observed in the live #admins room: ": " after the // user id, password in a backtick code span. let msg = "Successfully reset the password for user @triage:pr1ma.darkest.space: `hVfa6TpvIKnADoEJNWn9saHoI`"; assert_eq!( extract_new_password(msg).as_deref(), Some("hVfa6TpvIKnADoEJNWn9saHoI") ); } #[test] fn backtick_span_anywhere_in_prose() { // Wording around the code span is irrelevant — we anchor on the span. let msg = "Done. New password is: `hunter2` (store it now)"; assert_eq!(extract_new_password(msg).as_deref(), Some("hunter2")); } #[test] fn password_with_symbols_inside_span() { // '@' mid-token is fine — only a leading "@…:…" user-id shape is rejected. let msg = "Successfully reset the password for user @atlas:pr1ma.darkest.space: `N3wP@ss-w0rd!`"; assert_eq!(extract_new_password(msg).as_deref(), Some("N3wP@ss-w0rd!")); } #[test] fn codespan_userid_in_error_not_mistaken_for_password() { // An error that code-spans the user id must not yield it as a password. let msg = "Failed to reset password for `@sock:pr1ma.darkest.space` — user not found"; assert_eq!(extract_new_password(msg), None); } #[test] fn no_codespan_returns_none() { // No backtick span → unparseable here; the caller logs the raw body // so a genuinely new format surfaces instead of being mis-parsed. let msg = "Password reset complete. New password is: abc123XYZ"; assert_eq!(extract_new_password(msg), None); } #[test] fn non_password_message_returns_none() { let msg = "Command not recognised. Please try again."; assert_eq!(extract_new_password(msg), None); } #[test] fn empty_codespan_returns_none() { let msg = "Successfully reset the password for user @x:server: ``"; assert_eq!(extract_new_password(msg), None); } } /// Send a command to the Matrix admin room and poll for a bot response. /// /// Strategy: send the command, capture its `event_id`, then poll backwards /// (`dir=b&limit=20`) on each tick. Events in a backward response are /// newest-first; we walk the list until we find our own command `event_id`, /// then stop — everything before that marker in the list is a response that /// arrived *after* our command. We check `body` and `formatted_body` of /// every non-self message in that window. /// /// This avoids forward-pagination token direction issues that occur with /// some tuwunel builds: backward fetches are always anchored at the live /// timeline end and need no stored token. /// /// Generic over `T` so both password-returning and `()` callers share the loop. async fn admin_room_send_and_poll( client: &reqwest::Client, admin_token: &str, server_name: &str, room_url: &str, command: &str, check: impl Fn(&str) -> Option, ) -> Result { // 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}"); let send_resp = client .put(&send_url) .bearer_auth(admin_token) .json(&serde_json::json!({"msgtype": "m.text", "body": command})) .send() .await .context("matrix: PUT admin room message")?; if !send_resp.status().is_success() { let body = send_resp .json::() .await .unwrap_or_default(); anyhow::bail!("matrix: admin room send failed: {body}"); } let send_json = send_resp .json::() .await .unwrap_or_default(); let our_event_id = send_json["event_id"].as_str().unwrap_or("").to_owned(); // Poll for bot response: fetch the 20 most recent events (newest-first) // 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"); for _ in 0..15_u8 { tokio::time::sleep(std::time::Duration::from_secs(1)).await; let poll_json = client .get(&poll_url) .bearer_auth(admin_token) .send() .await .context("matrix: admin room poll")? .json::() .await .context("matrix: parse admin room poll response")?; if let Some(events) = poll_json["chunk"].as_array() { for event in events { // Stop as soon as we reach our own command — everything // older (further into the list) predates our request. // If our_event_id is empty (malformed PUT response), we skip // this guard and inspect all 20 events — slight risk of a // false match from an older response, but an acceptable fallback. if !our_event_id.is_empty() && event["event_id"].as_str() == Some(our_event_id.as_str()) { break; } if event["type"].as_str() != Some("m.room.message") { continue; } if event["sender"].as_str() == Some(own_user_id.as_str()) { continue; } // Check both plain body and formatted_body (HTML) — some // admin bots put the password only in formatted_body. let body = event["content"]["body"].as_str().unwrap_or_default(); let formatted = event["content"]["formatted_body"] .as_str() .unwrap_or_default(); for text in [body, formatted] { if let Some(result) = check(text) { return Ok(result); } } } } } anyhow::bail!( "matrix: admin room command timed out after 15 seconds. \ Command: '{command}'. No matching bot response received." ) } /// Reset a user's password via the Matrix admin room (`#admins:`). /// Sends `!admin users reset-password @:` as @hive, polls for the bot's /// response containing the new password. /// /// Returns the new password; caller is responsible for persisting it. async fn admin_room_reset_password( client: &reqwest::Client, admin_token: &str, server_name: &str, localpart: &str, ) -> Result { let room_id = discover_admin_room_id(client, admin_token, server_name).await?; let room_url = encode_room_id_for_url(&room_id); let command = format!("!admin users reset-password @{localpart}:{server_name}"); admin_room_send_and_poll( client, admin_token, server_name, &room_url, &command, extract_new_password, ) .await .with_context(|| { format!( "matrix: admin room reset-password for @{localpart}:{server_name}: \ no password response received within 15 seconds. \ Verify the admin room accepts '!admin users reset-password @user:server' commands." ) }) } /// Ensure `name` has a matrix user + token file on the local /// homeserver. Skips provisioning entirely if the token file already /// exists (treating a present token as proof the account is good). /// To force re-provisioning, delete the token file. /// /// When registration fails with `M_USER_IN_USE` (account exists in the /// homeserver but the token file was deleted) this falls back to /// `m.login.password` using the persisted `matrix-password` file. If /// that file is also missing, recovery requires manual intervention: /// `hivectl matrix create-user --password `. /// /// `client` is shared across the sweep so we build one reqwest /// connection pool for all agents rather than one per call. pub async fn ensure_user_for( client: &reqwest::Client, name: &str, register_token: &str, ) -> Result<()> { use std::os::unix::fs::PermissionsExt; let agent = hive_types::Ident::parse(name) .map_err(|e| anyhow::anyhow!("invalid agent name {name:?}: {e}"))?; let path = token_path(&agent); if path.exists() && let Ok(existing) = std::fs::read_to_string(&path) && !existing.trim().is_empty() { tracing::debug!(%name, "matrix: token already present"); return Ok(()); } // One-time migration: move the password from the old location inside // agent_notes_dir (purgeable) to the new location outside it. let new_pw_path = password_path(name); let old_pw_path = legacy_password_path(&agent); if !new_pw_path.exists() && old_pw_path.exists() { if let Some(parent) = new_pw_path.parent() { std::fs::create_dir_all(parent).ok(); } if let Err(e) = std::fs::rename(&old_pw_path, &new_pw_path) { // Rename across filesystems or read-only src — copy + delete. if let Ok(content) = std::fs::read(&old_pw_path) { if std::fs::write(&new_pw_path, &content).is_ok() { let _ = std::fs::remove_file(&old_pw_path); tracing::info!(%name, "matrix: migrated password file to non-purgeable location"); } } else { tracing::warn!(%name, rename_error = ?e, "matrix: password migration failed — could not read old path (old path stays)"); } } else { tracing::info!(%name, "matrix: migrated password file to non-purgeable location"); } } let password = random_password()?; let access_token = match register_user(client, name, register_token, &password).await { Ok(token) => { // Successful registration — persist the password so we can // fall back to login if the token file is deleted later. let pw_path = password_path(name); if let Some(parent) = pw_path.parent() { std::fs::create_dir_all(parent).ok(); } if let Err(e) = std::fs::write(&pw_path, format!("{password}\n")) { tracing::warn!(%name, error = ?e, "matrix: failed to persist password (token still saved)"); } else { let _ = std::fs::set_permissions(&pw_path, std::fs::Permissions::from_mode(0o600)); } token } Err(reg_err) if reg_err.to_string().contains("M_USER_IN_USE") => { // Account already exists — try to re-login with the stored password. tracing::info!(%name, "matrix: user already exists, attempting login with stored password"); let pw_path = password_path(name); let stored = if let Some(pw) = std::fs::read_to_string(&pw_path) .ok() .map(|s| s.trim().to_owned()) .filter(|s| !s.is_empty()) { pw } else { // Password file missing — attempt auto-recovery via admin API. // This covers the case where agent state dirs were wiped but the // homeserver still has the accounts. Requires the hive admin // token at /var/lib/hyperhive/matrix/admin-token. tracing::info!( %name, "matrix: stored password missing, attempting admin-API auto-recovery" ); match auto_reset_password(client, name).await { Ok(new_pw) => new_pw, Err(e) => { anyhow::bail!( "matrix: user {name} already exists but password is missing \ and admin auto-recovery failed ({e:#}) — run:\n\ hivectl matrix reset-password {name}\n\ hivectl matrix create-user {name}" ) } } }; login_user(client, name, &stored).await.with_context(|| { format!( "matrix: login fallback for {name} failed — if homeserver was wiped, delete \ the matrix-password file and retry" ) })? } Err(other) => return Err(other), }; // Write the token via hive-priv (root helper): hive-c0re runs as the // unprivileged `hive-core` user and cannot write to agent-owned state // directories directly. hive-priv writes the file 0600 and chowns it // to the agent user so it is readable from inside the container. crate::priv_client::write_agent_matrix_token(name, &access_token, None, None) .await .with_context(|| format!("matrix: write matrix-token for {name} via hive-priv"))?; tracing::info!(%name, "matrix: provisioned access token"); // Kick the daemon so it picks up the new token without waiting for a // full container restart — see docs/matrix.md::Provisioning flow. if let Err(e) = crate::priv_client::restart_matrix_daemon(name).await { tracing::warn!(%name, error = ?e, "matrix: could not restart hive-matrix-daemon (token written; daemon will reload on next container start)"); } else { tracing::info!(%name, "matrix: restarted hive-matrix-daemon to pick up new token"); } Ok(()) } /// Register a matrix account for `name` with the supplied `password` /// and return the freshly-minted access token. Unlike [`ensure_user_for`], /// the token is **not** persisted to disk — the caller is responsible /// for storing it. Used by `hivectl matrix create-user` for human /// (non-agent) accounts so we don't create stray /// `/var/lib/hyperhive/agents//` directories for users that /// aren't agents. For operator accounts the caller passes a real /// password so the operator can `m.login.password` into matrix web /// clients afterwards; for headless agent re-provisioning the caller /// can pass [`random_password`] to keep the existing throwaway /// behaviour. /// /// **Not idempotent** (unlike [`forge::provision_user_token`]): the /// matrix UIAA `/register` endpoint returns `M_USER_IN_USE` (HTTP 400) /// on second call for the same localpart. Callers re-running this for /// a known-existing matrix user should expect a hard error from this /// fn and route to a password-reset path instead. pub async fn provision_user_token( client: &reqwest::Client, name: &str, register_token: &str, password: &str, ) -> Result { register_user(client, name, register_token, password).await } /// Per-agent matrix sync: ensure the agent has a matrix account + token. /// All operations are idempotent; failures are logged as warnings but /// don't abort the caller. pub async fn sync_agent(client: &reqwest::Client, name: &str, register_token: &str) { if let Err(e) = ensure_user_for(client, name, register_token).await { tracing::warn!(%name, error = ?e, "matrix: ensure_user failed"); } } /// Standalone per-agent sync that handles its own setup: checks if /// hive-matrix is present, reads the registration token, and builds /// an HTTP client before delegating to [`sync_agent`]. Mirrors the /// 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 { return; } let register_token = match ensure_register_token() { Ok(t) => t, Err(e) => { tracing::warn!(%name, error = ?e, "matrix: ensure_register_token failed"); return; } }; let client = match reqwest::Client::builder() .timeout(std::time::Duration::from_secs(HTTP_TIMEOUT_SECS)) .build() { Ok(c) => c, Err(e) => { tracing::warn!(%name, error = ?e, "matrix: build HTTP client failed"); return; } }; sync_agent(&client, name, ®ister_token).await; } /// Ensure the hive system admin matrix user exists and its token is /// persisted at [`admin_token_path()`]. Must be called BEFORE /// [`ensure_all`]'s agent loop so this account is the first to register /// and becomes the homeserver admin automatically (Conduit/tuwunel: /// first registered user = admin). /// /// Idempotent — skips when the token file already exists and is /// non-empty. Does NOT promote the account via API (that requires /// admin rights which this fn bootstraps); on a fresh homeserver the /// first-registered rule fires automatically; on an existing homeserver /// the operator must promote the account once via /// `hivectl matrix promote-user hive` or the conduit admin room. pub async fn ensure_admin_user(client: &reqwest::Client, register_token: &str) -> Result<()> { use std::os::unix::fs::PermissionsExt; let path = admin_token_path(); if path.exists() && let Ok(existing) = std::fs::read_to_string(&path) && !existing.trim().is_empty() { tracing::debug!("matrix: hive admin token already present"); return Ok(()); } let password = random_password()?; let access_token = match register_user(client, HIVE_ADMIN_LOCALPART, register_token, &password) .await { Ok(token) => { let pw_path = password_path(HIVE_ADMIN_LOCALPART); if let Some(parent) = pw_path.parent() { std::fs::create_dir_all(parent).ok(); } if let Err(e) = std::fs::write(&pw_path, format!("{password}\n")) { tracing::warn!(error = ?e, "matrix: failed to persist hive admin password"); } else { let _ = std::fs::set_permissions(&pw_path, std::fs::Permissions::from_mode(0o600)); } token } Err(reg_err) if reg_err.to_string().contains("M_USER_IN_USE") => { tracing::info!("matrix: hive admin user already exists, re-logging in"); let pw_path = password_path(HIVE_ADMIN_LOCALPART); let stored = std::fs::read_to_string(&pw_path) .ok() .map(|s| s.trim().to_owned()) .filter(|s| !s.is_empty()) .with_context(|| { format!( "matrix: hive admin user exists but password missing at {} — \ manual recovery: reset password via admin API or conduit admin room", pw_path.display() ) })?; login_user(client, HIVE_ADMIN_LOCALPART, &stored).await? } Err(other) => return Err(other), }; if let Some(parent) = path.parent() { std::fs::create_dir_all(parent).ok(); } std::fs::write(&path, format!("{access_token}\n")) .with_context(|| format!("matrix: write hive admin token to {}", path.display()))?; let _ = std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600)); tracing::info!(path = %path.display(), "matrix: provisioned hive admin token"); Ok(()) } /// Promote a user to homeserver admin via the Matrix admin room /// (`#admins:`). Sends `!admin users make-user-admin @:` as /// @hive, polls for the bot's "Done:" success response. /// /// tuwunel 1.6.x does not implement `/_synapse/admin/v2/users`; all admin /// operations go through the admin room. pub async fn promote_user_to_admin( client: &reqwest::Client, admin_token: &str, localpart: &str, server_name: &str, ) -> Result<()> { let room_id = discover_admin_room_id(client, admin_token, server_name).await?; let room_url = encode_room_id_for_url(&room_id); let command = format!("!admin users make-user-admin @{localpart}:{server_name}"); admin_room_send_and_poll( client, admin_token, server_name, &room_url, &command, |body| { let lower = body.to_ascii_lowercase(); if lower.starts_with("done") || lower.contains("made") && lower.contains("admin") { Some(()) } else { None } }, ) .await .with_context(|| { format!( "matrix: admin room make-user-admin for @{localpart}:{server_name}: \ no success response within 15 seconds. \ Verify the admin room accepts '!admin users make-user-admin @user:server' commands." ) }) } /// Reset a user's password via the Matrix admin room (`#admins:`). /// /// Sends `!admin users reset-password @:` to the admin room as @hive, /// polls for the bot's response containing the new password, and persists /// it to the non-purgeable creds path so [`ensure_user_for`] can re-login /// on the next provisioning sweep. /// /// Returns the new password for use in subsequent `login_user` calls. pub async fn reset_user_password( client: &reqwest::Client, admin_token: &str, localpart: &str, server_name: &str, ) -> Result { let pw = admin_room_reset_password(client, admin_token, server_name, localpart) .await .with_context(|| { format!("matrix: admin-room password reset for @{localpart}:{server_name}") })?; persist_password(localpart, &pw); Ok(pw) } /// Persist the matrix password for `localpart` to the non-purgeable creds path. fn persist_password(localpart: &str, password: &str) { use std::os::unix::fs::PermissionsExt; let pw_path = password_path(localpart); if let Some(parent) = pw_path.parent() { std::fs::create_dir_all(parent).ok(); } if let Err(e) = std::fs::write(&pw_path, format!("{password}\n")) { tracing::warn!(%localpart, error = ?e, "matrix: failed to persist reset password"); } else { let _ = std::fs::set_permissions(&pw_path, std::fs::Permissions::from_mode(0o600)); } } /// Discover the matrix `server_name` from the running homeserver via /// `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 resp = client .get(&url) .send() .await .context("matrix: GET /_matrix/key/v2/server")?; let status = resp.status(); let body = resp .json::() .await .context("matrix: parse /_matrix/key/v2/server response")?; if !status.is_success() { anyhow::bail!("matrix: /_matrix/key/v2/server returned HTTP {status}, body: {body}"); } body["server_name"] .as_str() .map(str::to_owned) .with_context(|| { format!("matrix: /_matrix/key/v2/server response missing server_name field: {body}") }) } /// Read the hive admin access token from disk. Returns an error if it /// is absent — callers should gate their admin-API calls on this. pub fn read_admin_token() -> Result { let path = admin_token_path(); std::fs::read_to_string(&path) .ok() .map(|s| s.trim().to_owned()) .filter(|s| !s.is_empty()) .with_context(|| { format!( "hive admin matrix token not found at {} — \ ensure hive-c0re has started at least once with matrix enabled \ (it provisions the admin account on boot)", path.display() ) }) } /// Persist the hive Space room id to [`hive_space_room_id_path()`] (0600). fn persist_space_room_id(room_id: &str) -> Result<()> { use std::os::unix::fs::PermissionsExt; let path = hive_space_room_id_path(); if let Some(parent) = path.parent() { std::fs::create_dir_all(parent).ok(); } std::fs::write(&path, format!("{room_id}\n")) .with_context(|| format!("matrix: write space room_id to {}", path.display()))?; let _ = std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600)); Ok(()) } /// Scan the admin account's joined rooms for the canonical hive Space: the /// `m.space` whose name is [`HIVE_SPACE_NAME`]. Returns the first match /// (deterministic per the homeserver's joined-rooms order) so a lost /// room-id file recovers the existing space instead of spawning a /// duplicate. `None` if the homeserver is unreachable or no match exists. /// /// 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 joined: serde_json::Value = client .get(&joined_url) .bearer_auth(admin_token) .send() .await .ok()? .json() .await .ok()?; let rooms = joined["joined_rooms"].as_array()?; for room in rooms { let Some(room_id) = room.as_str() else { continue; }; 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 is_space = match client .get(&create_url) .bearer_auth(admin_token) .send() .await { Ok(r) if r.status().is_success() => r .json::() .await .ok() .is_some_and(|c| c["type"].as_str() == Some("m.space")), _ => false, }; if !is_space { 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_matches = match client.get(&name_url).bearer_auth(admin_token).send().await { Ok(r) if r.status().is_success() => r .json::() .await .ok() .is_some_and(|n| n["name"].as_str() == Some(HIVE_SPACE_NAME)), _ => false, }; if name_matches { return Some(room_id.to_owned()); } } None } /// Create (or recover) the hive Matrix Space and persist its room ID to /// [`hive_space_room_id_path()`]. The Space is a private `m.space` owned by /// `@hive`, identified by its hardcoded name [`HIVE_SPACE_NAME`] (no alias). /// /// Dedup strategy (single canonical space): /// 1. If the room-id file exists, reuse it. /// 2. Otherwise, rediscover by scanning the admin's joined rooms for the /// `m.space` named [`HIVE_SPACE_NAME`] and adopt it (re-persisting the /// file). This recovers the existing space after a state wipe instead /// of creating a duplicate. /// 3. Only if neither yields a room do we `createRoom`. /// /// # Errors /// /// 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 { // 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(); if !trimmed.is_empty() { tracing::debug!(room_id = %trimmed, "matrix: hive space already provisioned"); return Ok(trimmed); } } // 2. No stored id — rediscover the existing space by its hardcoded name // before creating a new one (prevents duplicate spaces after a wipe). if let Some(room_id) = find_space_by_name(client, admin_token).await { persist_space_room_id(&room_id)?; tracing::info!(%room_id, "matrix: recovered hive space by name"); return Ok(room_id); } // 3. Create the space (plain hardcoded name, no alias). let url = format!("{MATRIX_HTTP}/_matrix/client/v3/createRoom"); let body = serde_json::json!({ "name": HIVE_SPACE_NAME, "creation_content": { "type": "m.space" }, "preset": "private_chat", "visibility": "private", }); let resp = client .post(&url) .bearer_auth(admin_token) .json(&body) .send() .await .context("matrix: POST /createRoom (hive space)")?; let status = resp.status(); let json = resp .json::() .await .context("matrix: parse /createRoom response")?; if !status.is_success() { anyhow::bail!("matrix: createRoom HTTP {status}, body: {json}"); } let room_id = json["room_id"] .as_str() .with_context(|| format!("matrix: createRoom missing room_id: {json}"))? .to_owned(); persist_space_room_id(&room_id)?; tracing::info!(%room_id, "matrix: created hive space"); Ok(room_id) } /// Persist the hive chat room id to [`hive_chat_room_id_path()`] (0600). fn persist_chat_room_id(room_id: &str) -> Result<()> { use std::os::unix::fs::PermissionsExt; let path = hive_chat_room_id_path(); if let Some(parent) = path.parent() { std::fs::create_dir_all(parent).ok(); } std::fs::write(&path, format!("{room_id}\n")) .with_context(|| format!("matrix: write chat room_id to {}", path.display()))?; let _ = std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600)); Ok(()) } /// Scan the admin account's joined rooms for the canonical hive chat room: /// a non-space room named [`HIVE_CHAT_ROOM_NAME`]. Mirrors /// [`find_space_by_name`] so a lost room-id file recovers the existing chat /// 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 joined: serde_json::Value = client .get(&joined_url) .bearer_auth(admin_token) .send() .await .ok()? .json() .await .ok()?; let rooms = joined["joined_rooms"].as_array()?; for room in rooms { let Some(room_id) = room.as_str() else { continue; }; 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 is_space = match client .get(&create_url) .bearer_auth(admin_token) .send() .await { Ok(r) if r.status().is_success() => r .json::() .await .ok() .is_some_and(|c| c["type"].as_str() == Some("m.space")), _ => false, }; if is_space { 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_matches = match client.get(&name_url).bearer_auth(admin_token).send().await { Ok(r) if r.status().is_success() => r .json::() .await .ok() .is_some_and(|n| n["name"].as_str() == Some(HIVE_CHAT_ROOM_NAME)), _ => false, }; if name_matches { return Some(room_id.to_owned()); } } None } /// PUT a state event into `room_id` using the admin token. Idempotent — /// re-sending identical content is a no-op on the homeserver. async fn set_room_state( client: &reqwest::Client, admin_token: &str, room_id: &str, event_type: &str, state_key: &str, content: &serde_json::Value, ) -> Result<()> { 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 resp = client .put(&url) .bearer_auth(admin_token) .json(content) .send() .await .with_context(|| format!("matrix: PUT state {event_type} into {room_id}"))?; let status = resp.status(); if status.is_success() { return Ok(()); } let body = resp.json::().await.unwrap_or_default(); anyhow::bail!("matrix: set state {event_type} in {room_id}: HTTP {status}, body: {body}") } /// Create (or recover) the default hive chat room and wire it as a child of /// the hive Space, persisting its room id to [`hive_chat_room_id_path()`]. /// /// The room is a normal room (not an `m.space`) named [`HIVE_CHAT_ROOM_NAME`] /// with a `restricted` join rule allowing any member of the hive Space to /// join — so the operator (who is in the Space) and every agent can chat /// without needing an explicit invite. It's linked bidirectionally to the /// Space: `m.space.child` on the Space points at the room, `m.space.parent` /// on the room points back. Joining a Space does NOT auto-join its children /// (Matrix semantics), so this gives clients a concrete room to surface + /// join instead of an empty Space. /// /// Dedup mirrors [`ensure_hive_space`]: persisted id wins, else rediscover /// by name, else create. The space-child link is re-applied on every call /// (idempotent PUT) so a recovered room reconverges its hierarchy link. /// /// # Errors /// /// Returns an error if the homeserver is unreachable, `createRoom` fails, /// or the room-ID file cannot be written. A failure wiring the space-child /// link is logged but not fatal (the room still exists + is joinable). pub async fn ensure_hive_chat_room( client: &reqwest::Client, admin_token: &str, space_room_id: &str, server_name: &str, ) -> Result { // 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()) .ok() .map(|s| s.trim().to_owned()) .filter(|s| !s.is_empty()) { tracing::debug!(room_id = %id, "matrix: hive chat room already provisioned"); id } else if let Some(id) = find_chat_room_by_name(client, admin_token).await { persist_chat_room_id(&id)?; tracing::info!(room_id = %id, "matrix: recovered hive chat room by name"); id } 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 body = serde_json::json!({ "name": HIVE_CHAT_ROOM_NAME, "topic": HIVE_CHAT_ROOM_TOPIC, // Pin the room version explicitly: the `restricted` join rule // below needs room version >= 8. tuwunel's default is currently // higher, but pinning makes the dependency explicit so a future // homeserver-default change can't silently invalidate the // restricted rule (which would quietly fall back to invite-only // and break the "operator joins from the Space" path). "room_version": "10", "preset": "private_chat", "visibility": "private", "initial_state": [ { "type": "m.room.join_rules", "state_key": "", "content": { "join_rule": "restricted", "allow": [ { "type": "m.room_membership", "room_id": space_room_id } ] } }, { "type": "m.space.parent", "state_key": space_room_id, "content": { "via": [server_name], "canonical": true } } ] }); let resp = client .post(&url) .bearer_auth(admin_token) .json(&body) .send() .await .context("matrix: POST /createRoom (hive chat room)")?; let status = resp.status(); let json = resp .json::() .await .context("matrix: parse /createRoom response (chat room)")?; if !status.is_success() { anyhow::bail!("matrix: createRoom (chat) HTTP {status}, body: {json}"); } let id = json["room_id"] .as_str() .with_context(|| format!("matrix: createRoom (chat) missing room_id: {json}"))? .to_owned(); persist_chat_room_id(&id)?; tracing::info!(room_id = %id, "matrix: created hive chat room"); id }; // Wire the Space → room child link (idempotent). Without an // `m.space.child` carrying a `via`, the room won't surface in the Space // hierarchy. `suggested` hints clients to surface it prominently. let child_content = serde_json::json!({ "via": [server_name], "suggested": true, }); if let Err(e) = set_room_state( client, admin_token, space_room_id, "m.space.child", &room_id, &child_content, ) .await { tracing::warn!(error = ?e, "matrix: set m.space.child on hive space failed"); } Ok(room_id) } /// Invite `@{localpart}:{server_name}` to `room_id` using the admin /// account. Idempotent — treats already-member responses as success. async fn invite_to_room( client: &reqwest::Client, admin_token: &str, room_id: &str, localpart: &str, server_name: &str, ) -> Result<()> { let user_id = format!("@{localpart}:{server_name}"); invite_user_id(client, admin_token, room_id, &user_id).await } /// Fetch a user's current membership in a room via the admin token, or /// `None` if there is no membership event (never invited) or the lookup /// fails. Returns the raw membership string (`invite`, `join`, `leave`, …). async fn room_membership( client: &reqwest::Client, admin_token: &str, encoded_room_id: &str, user_id: &str, ) -> Option { // `:` 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}" ); let resp = client .get(&url) .bearer_auth(admin_token) .send() .await .ok()?; if !resp.status().is_success() { // 404 = no membership event yet; anything else we treat as "unknown" // and let the caller fall through to the invite attempt. return None; } let body = resp.json::().await.ok()?; body["membership"].as_str().map(ToOwned::to_owned) } /// Invite a fully-qualified Matrix user id (`@user:server`) to `room_id` /// using the admin token. Idempotent: a user who is already a member or /// already has a pending invite is left untouched (no fresh invite is sent, /// so they are not re-notified), and a 403 `M_FORBIDDEN` / `M_BAD_STATE` /// from a racing invite is still treated as success. async fn invite_user_id( client: &reqwest::Client, admin_token: &str, room_id: &str, user_id: &str, ) -> Result<()> { // `:` 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"); // Skip the invite entirely when the user is already invited or joined. // Re-POSTing an invite to a pending member re-sends the invite event, // which re-notifies the agent on every provisioning sweep. if let Some(membership) = room_membership(client, admin_token, &encoded_room_id, user_id).await && matches!(membership.as_str(), "invite" | "join") { tracing::debug!(%user_id, %room_id, %membership, "matrix: invite skipped (already a member/invited)"); return Ok(()); } let url = format!("{MATRIX_HTTP}/_matrix/client/v3/rooms/{encoded_room_id}/invite"); let resp = client .post(&url) .bearer_auth(admin_token) .json(&serde_json::json!({ "user_id": user_id })) .send() .await .with_context(|| format!("matrix: POST /rooms/.../invite for {user_id}"))?; let status = resp.status(); if status.is_success() { tracing::debug!(%user_id, %room_id, "matrix: invited to room"); return Ok(()); } // 403 with M_FORBIDDEN or M_BAD_STATE typically means the user is // already a member or has a pending invite — both are fine. if status == StatusCode::FORBIDDEN { let body = resp.json::().await.unwrap_or_default(); let errcode = body["errcode"].as_str().unwrap_or(""); if errcode == "M_FORBIDDEN" || errcode == "M_BAD_STATE" { tracing::debug!(%user_id, %room_id, %errcode, "matrix: invite skipped (already member/invited)"); return Ok(()); } anyhow::bail!("matrix: invite {user_id} to {room_id}: HTTP {status}, body: {body}"); } let body = resp.json::().await.unwrap_or_default(); anyhow::bail!("matrix: invite {user_id} to {room_id}: HTTP {status}, body: {body}") } /// Invite an arbitrary Matrix user to the hive Space (default) or an /// explicit `room_id` / alias. `user` may be a fully-qualified id /// (`@name:server`) or a bare localpart, which is qualified with the /// homeserver's `server_name`. Returns the resolved room id the invite /// targeted. Used by `hivectl matrix invite`. /// /// # Errors /// /// Returns an error if the admin token or `server_name` can't be read, /// the target room can't be resolved (no `--room` and no persisted hive /// space), or the invite POST fails for a reason other than the user /// already being a member / invited. pub async fn invite_user( client: &reqwest::Client, admin_token: &str, user: &str, room_override: Option<&str>, server_name: &str, ) -> Result { // Qualify a bare localpart to a full user id on the hive homeserver. let user_id = if user.starts_with('@') { user.to_owned() } else { format!("@{user}:{server_name}") }; // Resolve the room: explicit override (id or #alias) wins; otherwise // the persisted hive Space. let room_id = match room_override { Some(r) if r.starts_with('#') => resolve_room_alias(client, admin_token, r).await?, Some(r) if r.starts_with('!') => r.to_owned(), Some(r) => anyhow::bail!( "matrix: --room {r:?} is neither a room id nor an alias; \ prefix with '!' for a room id (!abc:server) or '#' for an \ alias (#name:server)" ), None => std::fs::read_to_string(hive_space_room_id_path()) .map(|s| s.trim().to_owned()) .context( "matrix: no --room given and no persisted hive space \ (run the hive-c0re matrix sweep first)", )?, }; invite_user_id(client, admin_token, &room_id, &user_id).await?; Ok(room_id) } /// Resolve a `#alias:server` to its room id via the directory API. async fn resolve_room_alias( client: &reqwest::Client, admin_token: &str, alias: &str, ) -> Result { let encoded = alias.replace('#', "%23").replace(':', "%3A"); let url = format!("{MATRIX_HTTP}/_matrix/client/v3/directory/room/{encoded}"); let resp = client .get(&url) .bearer_auth(admin_token) .send() .await .with_context(|| format!("matrix: GET directory for {alias}"))?; let status = resp.status(); let json = resp.json::().await.unwrap_or_default(); if !status.is_success() { anyhow::bail!("matrix: resolve alias {alias}: HTTP {status}, body: {json}"); } json["room_id"] .as_str() .map(str::to_owned) .ok_or_else(|| anyhow::anyhow!("matrix: alias {alias} response missing room_id: {json}")) } /// Sweep every existing container (manager + sub-agents) and ensure /// each has a matrix user + token on the local homeserver. Called at /// hive-c0re startup, alongside `forge::ensure_all`, and then /// periodically (see the caller in `main.rs`). No-op when the /// hive-matrix container isn't running. Per-step failures are logged /// but don't abort the sweep. /// /// Returns `true` when every step of the sweep succeeded, `false` when at /// least one step failed — the caller feeds this into a /// [`crate::stats::sweep_health::SweepHealth`] to raise a debounced /// 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 { tracing::debug!("matrix: hive-matrix container absent, skipping user sweep"); return true; } let mut ok = true; let register_token = match ensure_register_token() { Ok(t) => t, Err(e) => { tracing::warn!(error = ?e, "matrix: ensure_register_token failed"); return false; } }; // One HTTP client for the whole sweep — connection pool is // reused across agents. let client = match reqwest::Client::builder() .timeout(std::time::Duration::from_secs(HTTP_TIMEOUT_SECS)) .build() { Ok(c) => c, Err(e) => { tracing::warn!(error = ?e, "matrix: build HTTP client failed; skipping sweep"); return false; } }; // Provision hive admin user FIRST so it's the first registered // account on a fresh homeserver (Conduit/tuwunel makes the first // registered user admin automatically). if let Err(e) = ensure_admin_user(&client, ®ister_token).await { tracing::warn!(error = ?e, "matrix: ensure_admin_user failed"); ok = false; } let Ok(containers) = crate::lifecycle::list().await else { tracing::warn!("matrix: nixos-container list failed; skipping user sweep"); return false; }; let mut agent_names: Vec = Vec::new(); for c in &containers { let Some(name) = c.strip_prefix(crate::lifecycle::AGENT_PREFIX) else { continue; }; sync_agent(&client, name, ®ister_token).await; agent_names.push(name.to_owned()); } if !provision_space(&client, &agent_names).await { ok = false; } ok } /// Provision the hive Space + default chat room and invite every agent /// (+ the admin account) to both. Split out of [`ensure_all`] purely to /// keep that function under the `too_many_lines` threshold — this is the /// tail half of the same sequential sweep and shares its aggregate-bool, /// log-and-continue failure handling. async fn provision_space(client: &reqwest::Client, agent_names: &[String]) -> bool { let mut ok = true; // server_name is needed to form full Matrix user IDs for invites. let admin_token = match read_admin_token() { Ok(t) => t, Err(e) => { tracing::warn!(error = ?e, "matrix: skipping hive space provisioning (no admin token)"); return false; } }; // server_name first — the agent invites need it (fully-qualified user ids). let server_name = match discover_server_name(client).await { Ok(s) => s, Err(e) => { tracing::warn!(error = ?e, "matrix: discover_server_name failed; skipping space provisioning"); return false; } }; let room_id = match ensure_hive_space(client, &admin_token).await { Ok(id) => id, Err(e) => { tracing::warn!(error = ?e, "matrix: ensure_hive_space failed"); return false; } }; // Invite @hive admin first, then all agents. if let Err(e) = invite_to_room( client, &admin_token, &room_id, HIVE_ADMIN_LOCALPART, &server_name, ) .await { tracing::warn!(error = ?e, "matrix: invite @hive to space failed"); ok = false; } for name in agent_names { if let Err(e) = invite_to_room(client, &admin_token, &room_id, name, &server_name).await { tracing::warn!(%name, error = ?e, "matrix: invite agent to space failed"); ok = false; } } // Provision the default hive chat room as an m.space.child of the Space // and invite @hive + every agent. Joining a Space alone surfaces no // rooms to chat in (Matrix semantics — children aren't auto-joined), so // without this the Space is empty. The restricted join rule additionally // lets the operator (a Space member) join from the Space hierarchy. match ensure_hive_chat_room(client, &admin_token, &room_id, &server_name).await { Ok(chat_room_id) => { if let Err(e) = invite_to_room( client, &admin_token, &chat_room_id, HIVE_ADMIN_LOCALPART, &server_name, ) .await { tracing::warn!(error = ?e, "matrix: invite @hive to chat room failed"); ok = false; } for name in agent_names { if let Err(e) = invite_to_room(client, &admin_token, &chat_room_id, name, &server_name).await { tracing::warn!(%name, error = ?e, "matrix: invite agent to chat room failed"); ok = false; } } } Err(e) => { tracing::warn!(error = ?e, "matrix: ensure_hive_chat_room failed"); ok = false; } } ok } #[cfg(test)] mod tests { use super::*; #[test] fn random_hex_is_well_formed_and_correct_length() { let h = random_hex(16).expect("/dev/urandom readable"); assert_eq!(h.len(), 32); assert!(h.chars().all(|c| c.is_ascii_hexdigit())); } #[test] fn random_hex_two_calls_differ() { // Sanity check — not a statistical claim, just guards // against ever accidentally returning a constant. let a = random_hex(16).expect("/dev/urandom readable"); let b = random_hex(16).expect("/dev/urandom readable"); assert_ne!(a, b); } #[test] fn extract_access_token_pulls_from_success_body() { let body = serde_json::json!({ "user_id": "@alice:matrix.example.org", "access_token": "syt_abc123", "device_id": "ABC", }); assert_eq!(extract_access_token(&body).unwrap(), "syt_abc123"); } #[test] fn extract_access_token_errors_on_missing_field() { let body = serde_json::json!({"user_id": "@alice:matrix.example.org"}); let err = extract_access_token(&body).unwrap_err(); assert!(err.to_string().contains("missing access_token")); } }