argus's review note: current_room_state collapsed absent-state, transport failure and an unparseable body into one None, so a systematically failing GET was unobservable. Only one of the three actually hides. A transport error takes the PUT down with it one line later, and a 404 is the expected first-setup case — both stay at debug. A non-404 HTTP failure is the silent one: the read is broken while the write still succeeds, so the guard switches off and the sweep resumes emitting with nothing to show for it. That case, and only that case, warns. Keeping the warn narrow is the point: one that also fired on every expected 404 would train the reader to skip the line.
1842 lines
74 KiB
Rust
1842 lines
74 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 `services.hyperhive.swarm.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;
|
|
|
|
/// Client-server API base this daemon provisions against, from
|
|
/// `HIVE_MATRIX_API_URL` (set by `hive-c0re.nix` from
|
|
/// `services.hyperhive.swarm.matrix.apiUrl`).
|
|
///
|
|
/// `None` means **this hive has no homeserver to provision against** and
|
|
/// every matrix path no-ops — see [`is_present`]. There is deliberately no
|
|
/// fallback: `localhost:8008` is right only when the homeserver happens to
|
|
/// share this daemon's netns, and an address baked into the binary is one
|
|
/// that builds fine and then talks to the wrong machine. The nix module
|
|
/// supplies the loopback address when it is itself the thing running
|
|
/// tuwunel, where it is not a guess but a fact about what it just started.
|
|
///
|
|
/// Also not an agent-facing address either way. An agent has its own netns;
|
|
/// agents are handed the gateway vhost via `HIVE_MATRIX_URL`, and get
|
|
/// nothing at all when the hive has no vhost to offer.
|
|
fn matrix_http() -> Option<&'static str> {
|
|
static BASE: std::sync::OnceLock<Option<String>> = std::sync::OnceLock::new();
|
|
BASE.get_or_init(|| std::env::var("HIVE_MATRIX_API_URL").ok())
|
|
.as_deref()
|
|
}
|
|
|
|
/// [`matrix_http`] for the call sites that propagate with `?`.
|
|
///
|
|
/// # Errors
|
|
/// When no homeserver is configured. Reaching one of these paths at all
|
|
/// means an [`is_present`] gate was skipped, so the message names that
|
|
/// rather than the missing variable.
|
|
fn matrix_base() -> Result<&'static str> {
|
|
matrix_http().context(
|
|
"matrix: no homeserver configured \
|
|
(services.hyperhive.swarm.matrix.apiUrl / HIVE_MATRIX_API_URL) — \
|
|
this path should have been gated on matrix::is_present()",
|
|
)
|
|
}
|
|
/// Length (bytes) of the random registration token. 32 raw bytes ⇒
|
|
/// 64-char hex string; comfortable for a long-lived shared secret.
|
|
const REGISTER_TOKEN_BYTES: usize = 32;
|
|
/// 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()
|
|
}
|
|
|
|
/// Whether this hive has a homeserver to provision against.
|
|
///
|
|
/// **A configured API URL, not a local container.** It used to scan
|
|
/// `nixos-container list` for `hive-matrix`, which answers a different
|
|
/// question — "is the homeserver a container on this host" — and so made a
|
|
/// remote homeserver silently no-op no matter how it was addressed. The
|
|
/// nix module still supplies the loopback URL whenever it runs tuwunel
|
|
/// itself, so a co-located hive behaves exactly as before.
|
|
#[must_use]
|
|
pub fn is_present() -> bool {
|
|
matrix_http().is_some()
|
|
}
|
|
|
|
/// Read `n` cryptographic-quality bytes from `/dev/urandom` and return
|
|
/// 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 base = matrix_base()?;
|
|
let url = format!("{base}/_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 base = matrix_base()?;
|
|
let url = format!("{base}/_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> {
|
|
let base = matrix_base()?;
|
|
// #admins:server → %23admins%3A<server>
|
|
let encoded_alias = format!("%23admins%3A{server_name}");
|
|
let url = format!("{base}/_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> {
|
|
let base = matrix_base()?;
|
|
// Send the command; record the event_id so we can use it as an anchor.
|
|
let txn_id = random_hex(8)?;
|
|
let send_url =
|
|
format!("{base}/_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!("{base}/_matrix/client/v3/rooms/{room_url}/messages?dir=b&limit=20");
|
|
for _ in 0..15_u8 {
|
|
tokio::time::sleep(std::time::Duration::from_secs(1)).await;
|
|
let poll_json = client
|
|
.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() {
|
|
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:<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 base = matrix_base()?;
|
|
let url = format!("{base}/_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 base = matrix_http()?;
|
|
let joined_url = format!("{base}/_matrix/client/v3/joined_rooms");
|
|
let joined: serde_json::Value = client
|
|
.get(&joined_url)
|
|
.bearer_auth(admin_token)
|
|
.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!("{base}/_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!("{base}/_matrix/client/v3/rooms/{encoded}/state/m.room.name/");
|
|
let name_matches = match client.get(&name_url).bearer_auth(admin_token).send().await {
|
|
Ok(r) if r.status().is_success() => r
|
|
.json::<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> {
|
|
let base = matrix_base()?;
|
|
// 1. Stored room id wins (fast path).
|
|
if let Ok(existing) = std::fs::read_to_string(hive_space_room_id_path()) {
|
|
let trimmed = existing.trim().to_owned();
|
|
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!("{base}/_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 base = matrix_http()?;
|
|
let joined_url = format!("{base}/_matrix/client/v3/joined_rooms");
|
|
let joined: serde_json::Value = client
|
|
.get(&joined_url)
|
|
.bearer_auth(admin_token)
|
|
.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!("{base}/_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!("{base}/_matrix/client/v3/rooms/{encoded}/state/m.room.name/");
|
|
let name_matches = match client.get(&name_url).bearer_auth(admin_token).send().await {
|
|
Ok(r) if r.status().is_success() => r
|
|
.json::<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
|
|
}
|
|
|
|
/// Whether the state event still has to be written.
|
|
///
|
|
/// `current` is the room's existing content for this `(type, state_key)`,
|
|
/// or `None` when it could not be read. **Unreadable means write**: the
|
|
/// re-apply exists so a missing link gets repaired, and one redundant
|
|
/// event is cheaper than a hierarchy that never reconverges.
|
|
///
|
|
/// Comparison is `serde_json::Value` equality, which compares objects as
|
|
/// maps — key order in the response does not matter.
|
|
fn state_needs_write(current: Option<&serde_json::Value>, desired: &serde_json::Value) -> bool {
|
|
current != Some(desired)
|
|
}
|
|
|
|
/// Read the room's current content for one state event. `None` on any
|
|
/// failure — absent state, transport error, or an unparseable body are
|
|
/// all "we don't know", and [`state_needs_write`] turns that into a write.
|
|
///
|
|
/// One failure is louder than the rest, and it is the only one that hides:
|
|
/// **a non-404 HTTP failure means the read broke while the write still
|
|
/// works**, so the guard degrades to writing every time and the sweep
|
|
/// resumes waking the hive with nothing else to show for it. A transport
|
|
/// error needs no warning of its own — the PUT immediately after it fails
|
|
/// too, loudly — and a 404 is the expected first-setup case.
|
|
async fn current_room_state(
|
|
client: &reqwest::Client,
|
|
admin_token: &str,
|
|
url: &str,
|
|
) -> Option<serde_json::Value> {
|
|
let resp = match client.get(url).bearer_auth(admin_token).send().await {
|
|
Ok(resp) => resp,
|
|
Err(e) => {
|
|
tracing::debug!(error = ?e, url, "matrix: state read unreachable; writing");
|
|
return None;
|
|
}
|
|
};
|
|
let status = resp.status();
|
|
if !status.is_success() {
|
|
if status != reqwest::StatusCode::NOT_FOUND {
|
|
tracing::warn!(
|
|
%status,
|
|
url,
|
|
"matrix: state read failed while writes still work — the \
|
|
skip-if-unchanged guard is off and every sweep will re-emit"
|
|
);
|
|
}
|
|
return None;
|
|
}
|
|
match resp.json::<serde_json::Value>().await {
|
|
Ok(body) => Some(body),
|
|
Err(e) => {
|
|
tracing::debug!(error = ?e, url, "matrix: state read body unparseable; writing");
|
|
None
|
|
}
|
|
}
|
|
}
|
|
|
|
/// PUT a state event into `room_id` using the admin token, **skipping the
|
|
/// write when the room already carries identical content**.
|
|
///
|
|
/// The read is not an optimisation. A PUT of identical content is a no-op
|
|
/// on the room's *state*, and the homeserver still appends an event to the
|
|
/// *timeline* — so "idempotent" was true one level too high. Downstream,
|
|
/// an event is unread activity, which is a todo, which is a turn: a caller
|
|
/// re-applying a link on a periodic sweep wakes every agent in the room on
|
|
/// that sweep's cadence, forever. Measured at ~30 minutes per wake per
|
|
/// agent before this guard existed.
|
|
///
|
|
/// The self-healing property the re-apply exists for is unaffected: a
|
|
/// missing or divergent link still gets written.
|
|
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 base = matrix_base()?;
|
|
let encoded_room = encode_room_id_for_url(room_id);
|
|
let encoded_key = encode_room_id_for_url(state_key);
|
|
let url =
|
|
format!("{base}/_matrix/client/v3/rooms/{encoded_room}/state/{event_type}/{encoded_key}");
|
|
let current = current_room_state(client, admin_token, &url).await;
|
|
if !state_needs_write(current.as_ref(), content) {
|
|
tracing::debug!(
|
|
%room_id,
|
|
event_type,
|
|
"matrix: state already current, skipping PUT (no timeline event)"
|
|
);
|
|
return Ok(());
|
|
}
|
|
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-checked on every call
|
|
/// so a recovered room reconverges its hierarchy link — and written only
|
|
/// when it differs, see [`set_room_state`].
|
|
///
|
|
/// # 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> {
|
|
let base = matrix_base()?;
|
|
// 1. Stored room id wins (fast path). 2. Rediscover by name before
|
|
// creating (prevents duplicates after a state wipe). 3. Create.
|
|
let room_id = if let Some(id) = std::fs::read_to_string(hive_chat_room_id_path())
|
|
.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!("{base}/_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. 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> {
|
|
let base = matrix_http()?;
|
|
// `:` must be percent-encoded in both the room-id and user-id path
|
|
// segments; `@` and `!` are permitted path characters per RFC 3986.
|
|
let encoded_user = user_id.replace(':', "%3A");
|
|
let url = format!(
|
|
"{base}/_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<()> {
|
|
let base = matrix_base()?;
|
|
// `:` must be percent-encoded in the room-id path segment; `!` is
|
|
// permitted in URL path characters per RFC 3986.
|
|
let encoded_room_id = room_id.replace(':', "%3A");
|
|
|
|
// 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!("{base}/_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 base = matrix_base()?;
|
|
let encoded = alias.replace('#', "%23").replace(':', "%3A");
|
|
let url = format!("{base}/_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() {
|
|
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<String> = 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()));
|
|
}
|
|
|
|
/// The steady state. This is the whole point of the guard: the sweep
|
|
/// runs forever, and every write it makes is a wake for every agent
|
|
/// in the room.
|
|
#[test]
|
|
fn state_matching_the_room_is_not_rewritten() {
|
|
let desired = serde_json::json!({ "via": ["example.org"], "suggested": true });
|
|
let current = desired.clone();
|
|
assert!(!state_needs_write(Some(¤t), &desired));
|
|
}
|
|
|
|
/// Key order in the homeserver's response must not force a write —
|
|
/// the guard leans on `serde_json::Value` comparing objects as maps,
|
|
/// and a byte- or order-sensitive comparison would silently degrade
|
|
/// to writing every time while still looking correct.
|
|
#[test]
|
|
fn state_matching_but_reordered_is_not_rewritten() {
|
|
let desired = serde_json::json!({ "via": ["example.org"], "suggested": true });
|
|
let current = serde_json::json!({ "suggested": true, "via": ["example.org"] });
|
|
assert!(!state_needs_write(Some(¤t), &desired));
|
|
}
|
|
|
|
#[test]
|
|
fn diverged_state_is_rewritten() {
|
|
let desired = serde_json::json!({ "via": ["example.org"], "suggested": true });
|
|
let current = serde_json::json!({ "via": ["old.example.org"], "suggested": true });
|
|
assert!(state_needs_write(Some(¤t), &desired));
|
|
}
|
|
|
|
/// Fail-open, and deliberately so: an unreadable current state must
|
|
/// write. The re-apply exists to repair a missing link, so "we don't
|
|
/// know" has to behave like "it's missing", not like "it's fine".
|
|
#[test]
|
|
fn unknown_state_is_written() {
|
|
let desired = serde_json::json!({ "via": ["example.org"], "suggested": true });
|
|
assert!(state_needs_write(None, &desired));
|
|
}
|
|
|
|
#[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"));
|
|
}
|
|
}
|