hyperhive/hive-c0re/src/matrix.rs

1692 lines
68 KiB
Rust

//! Optional matrix-tuwunel wiring: shared registration token (host) +
//! per-agent UIAA registration → `<agent-state>/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:<port>` 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/<name>-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<String> {
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<String> {
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::<serde_json::Value>()
.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<String> {
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<String> {
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<String> {
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<String> {
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::<serde_json::Value>()
.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<String> {
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:<server>` alias.
async fn discover_admin_room_id(
client: &reqwest::Client,
admin_token: &str,
server_name: &str,
) -> Result<String> {
// #admins:server → %23admins%3A<server>
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::<serde_json::Value>()
.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: `<password>`"
/// 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<String> {
// 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<T>(
client: &reqwest::Client,
admin_token: &str,
server_name: &str,
room_url: &str,
command: &str,
check: impl Fn(&str) -> Option<T>,
) -> Result<T> {
// 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::<serde_json::Value>()
.await
.unwrap_or_default();
anyhow::bail!("matrix: admin room send failed: {body}");
}
let send_json = send_resp
.json::<serde_json::Value>()
.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::<serde_json::Value>()
.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:<server>`).
/// Sends `!admin users reset-password @<localpart>:<server>` 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<String> {
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 <name> --password <pw>`.
///
/// `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/<name>/` 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<String> {
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, &register_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:<server>`). Sends `!admin users make-user-admin @<localpart>:<server>` 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:<server>`).
///
/// Sends `!admin users reset-password @<localpart>:<server>` 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<String> {
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<String> {
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::<serde_json::Value>()
.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<String> {
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<String> {
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::<serde_json::Value>()
.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::<serde_json::Value>()
.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<String> {
// 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::<serde_json::Value>()
.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<String> {
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::<serde_json::Value>()
.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::<serde_json::Value>()
.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::<serde_json::Value>().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<String> {
// 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::<serde_json::Value>()
.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<String> {
// `:` 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::<serde_json::Value>().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::<serde_json::Value>().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::<serde_json::Value>().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<String> {
// 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<String> {
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::<serde_json::Value>().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, &register_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<String> = Vec::new();
for c in &containers {
let Some(name) = c.strip_prefix(crate::lifecycle::AGENT_PREFIX) else {
continue;
};
sync_agent(&client, name, &register_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"));
}
}