Three leftovers from the rename, plus the gating prose job.
`nix/packages/default.nix` still described the minter as the "matrix
admin credential's minter", and shipped that claim in the package's
`meta.description` — a PR-visible string.
`promote_user_to_admin`'s doc comment pointed at the `admin_execute`
entry in `hive-matrix.nix` as the thing that bootstraps the first
admin. That entry is gone, so the comment referenced nothing. It now
records that the account is ordinary, that the call therefore has no
working sender, and that rehoming at swarm level is the fix rather than
re-granting. `reset_user_password` gained the matching warning; it had
none.
The prose fixes clear all 8 `CI / prose lint (vale, errors)` failures,
all of which were in docs this branch touches. No vale config change,
no exception, no carve-out: contractions, one recast sentence, one
de-hyphenation and one dropped "simply".
Tense: four docs described system behaviour in the future ("will
refuse", "will fetch and trust"). Reference docs get read from the
other side of the change, so they say what the system does.
2047 lines
84 KiB
Rust
2047 lines
84 KiB
Rust
//! Optional matrix-tuwunel wiring: the hive's appservice identity (host) +
|
|
//! per-agent account creation → `<agent-state>/matrix-token`. No-op
|
|
//! when the `hive-matrix` container isn't running, so operators who
|
|
//! haven't flipped `services.hyperhive.deploy.matrix.enable = true` pay
|
|
//! nothing.
|
|
//!
|
|
//! Accounts are created **as the hive's appservice**, not by presenting a
|
|
//! shared registration token in a UIAA flow. The difference that matters
|
|
//! here is not the round-trip count: an appservice token is an *identity*
|
|
//! the homeserver knows, so the secret never has to be the same on both
|
|
//! sides of the wire, and account creation does not depend on registration
|
|
//! being open to anyone who learns a token.
|
|
//!
|
|
//! See `docs/integrations/matrix.md::Provisioning flow (appservice)` for the
|
|
//! registration file's shape, how its token reaches both halves, and the
|
|
//! 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 the hyperhive NixOS module 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()",
|
|
)
|
|
}
|
|
/// HTTP timeout for registration round-trips. Account creation is one
|
|
/// POST; 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. Not an agent; has
|
|
/// no state dir.
|
|
///
|
|
/// Also the `sender_localpart` of the hive's appservice registration,
|
|
/// which is what creates this account on a homeserver that has never had
|
|
/// one: the homeserver creates a registration's sender user itself, at
|
|
/// startup, before it accepts a request. See
|
|
/// `nix/host-modules/hive-matrix.nix`, where this same literal appears as
|
|
/// `hiveLocalpart`. **The two must match**; nothing wires an override
|
|
/// across.
|
|
///
|
|
/// An ordinary account, with no homeserver-admin standing: what it
|
|
/// provisions — the Space, the chat room, the invites — it provisions as
|
|
/// the creator of those rooms.
|
|
pub const HIVE_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:` account's matrix access token. Outside every
|
|
/// purgeable path — not deleted by `destroy --purge` on any agent.
|
|
#[must_use]
|
|
pub fn hive_token_path() -> PathBuf {
|
|
crate::paths::matrix_hive_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)
|
|
}
|
|
|
|
/// Read the hive's appservice token — the `as_token` of the registration
|
|
/// the homeserver loaded at boot. Every account this module creates is
|
|
/// authorised by it.
|
|
///
|
|
/// **Reads, never mints**, unlike the registration token it replaced.
|
|
/// That token was the whole agreement, so whichever side wrote it first
|
|
/// was right; this one has a second half — the registration file naming
|
|
/// it, which only the nix side writes. A token minted here would be a
|
|
/// token the homeserver has never heard of, and the failure would surface
|
|
/// as every request being refused rather than as a missing file.
|
|
///
|
|
/// # Errors
|
|
/// When the file is absent or empty. That means the host activation
|
|
/// script has not run on this generation yet; callers log it and leave
|
|
/// existing accounts alone rather than trying to proceed.
|
|
pub fn read_appservice_token() -> Result<String> {
|
|
let path = crate::paths::matrix_appservice_token();
|
|
std::fs::read_to_string(&path)
|
|
.ok()
|
|
.map(|s| s.trim().to_owned())
|
|
.filter(|s| !s.is_empty())
|
|
.with_context(|| {
|
|
format!(
|
|
"matrix appservice token not found at {} — it is minted by the \
|
|
hive-matrix activation script, which also renders the registration \
|
|
file naming it; deploy the hive-matrix module (or re-run \
|
|
`nixos-rebuild switch`) before provisioning matrix users",
|
|
path.display()
|
|
)
|
|
})
|
|
}
|
|
|
|
/// 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 the registration POST as the appservice and parse the response.
|
|
/// Returns `Ok((status, body))` on any completed HTTP round-trip
|
|
/// (including the `M_USER_IN_USE` 400 the caller treats as "already
|
|
/// exists"); errors only on transport failure.
|
|
async fn register_post(
|
|
client: &reqwest::Client,
|
|
as_token: &str,
|
|
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)
|
|
.bearer_auth(as_token)
|
|
.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)
|
|
}
|
|
|
|
/// Create the matrix account for `agent` as the hive's appservice and
|
|
/// return an access token for it. One round-trip: an appservice-typed
|
|
/// registration needs no UIAA stage at all, so there is no session to
|
|
/// carry and no shared secret to present.
|
|
///
|
|
/// The account is created **by** the appservice but is an ordinary user
|
|
/// afterwards — it gets its own device and its own access token, and the
|
|
/// agent authenticates with that rather than with anything the hive
|
|
/// holds. The `as_token` never leaves the host.
|
|
///
|
|
/// 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.
|
|
///
|
|
/// # Errors
|
|
/// Propagates the homeserver's own body, which is what the
|
|
/// `M_USER_IN_USE` callers match on. A `M_EXCLUSIVE` body means the
|
|
/// localpart falls outside the appservice's namespace — the registration
|
|
/// file's `namespaces.users` regex is the place to look, not this call.
|
|
async fn register_user(
|
|
client: &reqwest::Client,
|
|
agent: &str,
|
|
as_token: &str,
|
|
password: &str,
|
|
) -> Result<String> {
|
|
let localpart = user_localpart(agent);
|
|
let body = serde_json::json!({
|
|
// What makes this an appservice registration rather than an
|
|
// ordinary one. Without it the homeserver treats the request as a
|
|
// normal client's and asks for a UIAA flow — even holding the
|
|
// as_token.
|
|
"type": "m.login.application_service",
|
|
"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, as_token, &body).await?;
|
|
if !status.is_success() {
|
|
anyhow::bail!("matrix: /register as appservice HTTP {status}, body: {body}");
|
|
}
|
|
extract_access_token(&body)
|
|
}
|
|
|
|
/// Log in as an **existing** account using the hive's appservice token,
|
|
/// and return a fresh access token for it. No password involved: the
|
|
/// appservice is authorised for every localpart in its namespace, so it
|
|
/// can mint a session for one without knowing anything about the account.
|
|
///
|
|
/// This is the recovery path that used to need a stored password or an
|
|
/// admin-room password reset — an account whose token file was lost is
|
|
/// re-tokened from the hive's own identity instead. The device id matches
|
|
/// [`register_user`]'s, so a re-login replaces that device's token rather
|
|
/// than accumulating devices.
|
|
async fn appservice_login(client: &reqwest::Client, as_token: &str, agent: &str) -> Result<String> {
|
|
let base = matrix_base()?;
|
|
let url = format!("{base}/_matrix/client/v3/login");
|
|
let body = serde_json::json!({
|
|
"type": "m.login.application_service",
|
|
"identifier": {
|
|
"type": "m.id.user",
|
|
"user": user_localpart(agent),
|
|
},
|
|
"device_id": format!("hyperhive-{agent}"),
|
|
"initial_device_display_name": format!("hyperhive ({agent})"),
|
|
});
|
|
let resp = client
|
|
.post(&url)
|
|
.bearer_auth(as_token)
|
|
.json(&body)
|
|
.send()
|
|
.await
|
|
.context("matrix: POST /login as appservice")?;
|
|
let status = resp.status();
|
|
let json = resp
|
|
.json::<serde_json::Value>()
|
|
.await
|
|
.context("matrix: parse appservice /login response")?;
|
|
if !status.is_success() {
|
|
anyhow::bail!("matrix: appservice /login HTTP {status} for {agent}, body: {json}");
|
|
}
|
|
extract_access_token(&json)
|
|
}
|
|
|
|
/// 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 through the admin
|
|
/// room when the locally stored password is missing. Returns the new
|
|
/// password (already persisted to [`password_path`]) on success.
|
|
///
|
|
/// ⚠️ Needs an **admin sender**, which `@hive:` is not — the reset is a
|
|
/// `!admin` command and tuwunel only treats a message as a command when
|
|
/// its sender is an admin in that room. So this recovery path fails until
|
|
/// the two admin operations are rehomed at swarm level; the ordinary
|
|
/// route (the stored password, or an appservice login) is unaffected.
|
|
///
|
|
/// 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 hive_token = read_hive_token()
|
|
.context("matrix: the @hive: access token is unavailable for auto-recovery")?;
|
|
let server_name = discover_server_name(client)
|
|
.await
|
|
.context("matrix: discover_server_name for auto-recovery")?;
|
|
let effective_password = reset_user_password(client, &hive_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,
|
|
hive_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(hive_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,
|
|
hive_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(hive_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_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(hive_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,
|
|
hive_token: &str,
|
|
server_name: &str,
|
|
localpart: &str,
|
|
) -> Result<String> {
|
|
let room_id = discover_admin_room_id(client, hive_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,
|
|
hive_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, as_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, as_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 and its token file was lost. The
|
|
// appservice can mint a session for any account in its
|
|
// namespace, so this needs no password and no admin rights —
|
|
// try it before the password paths below, which exist for
|
|
// accounts created before the appservice did (or named
|
|
// outside its namespace) and stay as the fallback.
|
|
tracing::info!(%name, "matrix: user already exists, logging in as the appservice");
|
|
match appservice_login(client, as_token, name).await {
|
|
Ok(token) => return finish_user_provisioning(name, &token).await,
|
|
Err(e) => tracing::warn!(
|
|
%name,
|
|
error = ?e,
|
|
"matrix: appservice login failed; falling back to the 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 through the
|
|
// admin room.
|
|
// This covers the case where agent state dirs were wiped but the
|
|
// homeserver still has the accounts. Requires the `@hive:`
|
|
// token at /var/lib/hyperhive/matrix/access-token, and an admin
|
|
// sender, which `@hive:` no longer is.
|
|
tracing::info!(
|
|
%name,
|
|
"matrix: stored password missing, attempting admin-room 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),
|
|
};
|
|
|
|
finish_user_provisioning(name, &access_token).await
|
|
}
|
|
|
|
/// Persist a freshly-obtained agent access token and kick the agent's
|
|
/// matrix daemon. Shared by every way [`ensure_user_for`] can end up
|
|
/// holding a token — creation, appservice login, password login — so a
|
|
/// new recovery path cannot forget half of it.
|
|
async fn finish_user_provisioning(name: &str, access_token: &str) -> Result<()> {
|
|
// 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/integrations/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 [`crate::forge::provision_user_token`]): the
|
|
/// matrix `/register` endpoint returns `M_USER_IN_USE` (HTTP 400) on a
|
|
/// second call for the same localpart, appservice-authorised or not.
|
|
/// 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,
|
|
as_token: &str,
|
|
password: &str,
|
|
) -> Result<String> {
|
|
register_user(client, name, as_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, as_token: &str) {
|
|
if let Err(e) = ensure_user_for(client, name, as_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 appservice 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 as_token = match read_appservice_token() {
|
|
Ok(t) => t,
|
|
Err(e) => {
|
|
tracing::warn!(%name, error = ?e, "matrix: read_appservice_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, &as_token).await;
|
|
}
|
|
|
|
/// Ensure the `@hive:` matrix user exists and that its access token is
|
|
/// persisted at [`hive_token_path()`].
|
|
///
|
|
/// **Nothing here depends on registration order, and nothing here is
|
|
/// privileged.** The account used to have to be the first ever
|
|
/// registered, to win tuwunel's automatic first-user grant — a rule that
|
|
/// cannot fire for an appservice-created account at all. It is now an
|
|
/// ordinary account: the homeserver creates it because it is the
|
|
/// appservice registration's `sender_localpart`, and everything the hive
|
|
/// provisions with it, it provisions as the creator of those rooms.
|
|
///
|
|
/// Idempotent — skips the account work when the token file already exists
|
|
/// and is non-empty.
|
|
///
|
|
/// The token is taken from the **swarm secret store** when it is there:
|
|
/// `swarm-matrix-minter`, the oneshot inside the matrix container, publishes
|
|
/// it under an identity of its own, and taking it from there is what lets a
|
|
/// hive that holds no `as_token` have an admin at all. The mint ladder below
|
|
/// stays as the fallback for a store that is empty, unconfigured or
|
|
/// unreachable — which is every swarm whose matrix container predates that
|
|
/// minter.
|
|
pub async fn ensure_hive_user(client: &reqwest::Client, as_token: &str) -> Result<()> {
|
|
use std::os::unix::fs::PermissionsExt;
|
|
let path = hive_token_path();
|
|
if path.exists()
|
|
&& let Ok(existing) = std::fs::read_to_string(&path)
|
|
&& !existing.trim().is_empty()
|
|
{
|
|
tracing::debug!("matrix: the @hive: access token is already present");
|
|
return Ok(());
|
|
}
|
|
if let Some(token) = stored_hive_token().await {
|
|
return persist_hive_token(&path, &token);
|
|
}
|
|
let password = random_password()?;
|
|
let access_token = match register_user(client, HIVE_LOCALPART, as_token, &password).await {
|
|
Ok(token) => {
|
|
let pw_path = password_path(HIVE_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 the @hive: account 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") => {
|
|
// The expected path, not an edge case: this account is the
|
|
// appservice's own `sender_localpart`, so the homeserver
|
|
// creates it when it loads the registration — before
|
|
// hive-c0re gets a chance to ask. An appservice login needs
|
|
// no password, which is just as well since an account the
|
|
// homeserver created has none.
|
|
tracing::info!("matrix: the @hive: user already exists, logging in as the appservice");
|
|
match appservice_login(client, as_token, HIVE_LOCALPART).await {
|
|
Ok(token) => token,
|
|
Err(e) => {
|
|
tracing::warn!(error = ?e, "matrix: appservice login for @hive: failed; falling back to the stored password");
|
|
let pw_path = password_path(HIVE_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: the @hive: user exists, appservice login failed, and no \
|
|
password is stored at {} — check that the registration file's \
|
|
namespace covers @{HIVE_LOCALPART} and that the homeserver \
|
|
loaded it",
|
|
pw_path.display()
|
|
)
|
|
})?;
|
|
login_user(client, HIVE_LOCALPART, &stored).await?
|
|
}
|
|
}
|
|
}
|
|
Err(other) => return Err(other),
|
|
};
|
|
persist_hive_token(&path, &access_token)
|
|
}
|
|
|
|
/// Write the `@hive:` account's access token to `path`, 0600, creating the
|
|
/// directory if it is not there.
|
|
///
|
|
/// Shared by both arms of [`ensure_hive_user`] rather than duplicated into
|
|
/// the store one: the file's mode is the only thing keeping an unprivileged
|
|
/// reader off the hive's matrix credential, and a second copy of that decision
|
|
/// is one that can be edited alone.
|
|
fn persist_hive_token(path: &std::path::Path, access_token: &str) -> Result<()> {
|
|
use std::os::unix::fs::PermissionsExt;
|
|
|
|
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 the @hive: access token to {}",
|
|
path.display()
|
|
)
|
|
})?;
|
|
let _ = std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600));
|
|
tracing::info!(path = %path.display(), "matrix: provisioned the @hive: access token");
|
|
Ok(())
|
|
}
|
|
|
|
/// Fetch the `@hive:` access token `swarm-matrix-minter` published, under
|
|
/// this hive's own store identity.
|
|
///
|
|
/// The cert role is the hive's name, straight out of `HYPERHIVE_HIVE_NAME` —
|
|
/// the same role string `workers::credential` logs in with, and already in
|
|
/// this process's environment, so the store read costs no plumbing through
|
|
/// [`ensure_all`]. No new grant either: a hive's policy already covers the
|
|
/// whole `swarm/services/*` tree the minter writes into.
|
|
///
|
|
/// `None`, never an error, for every way this can come up empty — no hive
|
|
/// name, no `BAO_*` identity, an unreachable store, nothing at the path. All
|
|
/// four mean the same thing to the caller ("mint it the old way"), and three
|
|
/// of them are the ordinary state of a swarm that has not deployed the minter
|
|
/// yet, so raising would turn a supported deployment into a warning every
|
|
/// sweep.
|
|
///
|
|
/// 🩸 Logs the store **path** and never the value.
|
|
async fn stored_hive_token() -> Option<String> {
|
|
let hive = std::env::var("HYPERHIVE_HIVE_NAME")
|
|
.ok()
|
|
.filter(|h| !h.is_empty())?;
|
|
let path = swarm_secret_client::matrix::hive_token_path();
|
|
let store = match swarm_secret_client::SecretStore::from_env(&hive).await {
|
|
Ok(store) => store,
|
|
Err(e) => {
|
|
tracing::debug!(error = %e, "matrix: no swarm secret store to read the @hive: access token from");
|
|
return None;
|
|
}
|
|
};
|
|
match store
|
|
.read::<swarm_secret_client::matrix::Credential>(&path)
|
|
.await
|
|
{
|
|
Ok(credential) if !credential.value.trim().is_empty() => {
|
|
tracing::info!(%path, "matrix: taking the @hive: access token from the swarm store");
|
|
Some(credential.value)
|
|
}
|
|
Ok(_) => {
|
|
tracing::warn!(%path, "matrix: the stored @hive: credential is empty; minting instead");
|
|
None
|
|
}
|
|
Err(e) => {
|
|
tracing::debug!(%path, error = %e, "matrix: no @hive: credential in the store; minting instead");
|
|
None
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Whether an admin-room reply says a `make-user-admin` succeeded.
|
|
///
|
|
/// Three spellings, because the reply is prose and prose changes between
|
|
/// builds. tuwunel v1.9.0's is `"<user id> has been granted admin
|
|
/// privileges."` — which the original two patterns here (`done…`,
|
|
/// `made…admin`) do not match at all, so a promotion that had already
|
|
/// worked was reported as a 15-second timeout. The older spellings are
|
|
/// kept: a homeserver is not necessarily the version this was written
|
|
/// against.
|
|
fn is_make_admin_success(body: &str) -> Option<()> {
|
|
let lower = body.to_ascii_lowercase();
|
|
let says_ok = lower.starts_with("done")
|
|
|| lower.contains("granted admin privileges")
|
|
|| (lower.contains("made") && lower.contains("admin"));
|
|
says_ok.then_some(())
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod is_make_admin_success_tests {
|
|
use super::is_make_admin_success;
|
|
|
|
/// The reply tuwunel v1.9.0 actually sends
|
|
/// (`src/admin/user/make_user_admin.rs`). This is the case the
|
|
/// pre-existing matcher missed.
|
|
#[test]
|
|
fn tuwunel_1_9_grant_reply() {
|
|
let msg = "@hive:pr1ma.darkest.space has been granted admin privileges.";
|
|
assert_eq!(is_make_admin_success(msg), Some(()));
|
|
}
|
|
|
|
#[test]
|
|
fn older_spellings_still_match() {
|
|
assert_eq!(
|
|
is_make_admin_success("Done: user is now an admin"),
|
|
Some(())
|
|
);
|
|
assert_eq!(is_make_admin_success("Made @x:y an admin"), Some(()));
|
|
}
|
|
|
|
/// The control: an unrelated or failing reply must not read as
|
|
/// success, or a failed promotion returns Ok and the warning that
|
|
/// would have named it never fires.
|
|
#[test]
|
|
fn failures_and_noise_do_not_match() {
|
|
assert_eq!(is_make_admin_success("Command not recognised."), None);
|
|
assert_eq!(is_make_admin_success("User @x:y does not exist"), None);
|
|
}
|
|
}
|
|
|
|
/// 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 success reply.
|
|
///
|
|
/// ⚠️ Requires the **sender** to be an admin already — tuwunel only
|
|
/// treats a message as a command when its sender is in the admin room.
|
|
/// `@hive:` is an ordinary account (`hive-matrix.nix` grants it no
|
|
/// `admin_execute` promotion), so this call has no working sender from
|
|
/// the hive and fails with the admin room's refusal. Promotion is a
|
|
/// swarm-level operation and is being rehomed as such; this stays here,
|
|
/// failing loudly, rather than justifying an over-privileged token that
|
|
/// all 13 ordinary call sites would also carry.
|
|
///
|
|
/// Goes through the admin room rather than a direct HTTP call because
|
|
/// tuwunel implements parts of the Synapse admin API but not user
|
|
/// creation, and upstream does not intend to add it. This is the
|
|
/// intended long-term mechanism, not a stopgap awaiting an upstream fix.
|
|
pub async fn promote_user_to_admin(
|
|
client: &reqwest::Client,
|
|
hive_token: &str,
|
|
localpart: &str,
|
|
server_name: &str,
|
|
) -> Result<()> {
|
|
let room_id = discover_admin_room_id(client, hive_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,
|
|
hive_token,
|
|
server_name,
|
|
&room_url,
|
|
&command,
|
|
is_make_admin_success,
|
|
)
|
|
.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.
|
|
///
|
|
/// ⚠️ Same admin-**sender** requirement as [`promote_user_to_admin`], and
|
|
/// the same consequence: `@hive:` is an ordinary account, so this fails
|
|
/// from the hive until the operation is rehomed at swarm level.
|
|
///
|
|
/// Returns the new password for use in subsequent `login_user` calls.
|
|
pub async fn reset_user_password(
|
|
client: &reqwest::Client,
|
|
hive_token: &str,
|
|
localpart: &str,
|
|
server_name: &str,
|
|
) -> Result<String> {
|
|
let pw = admin_room_reset_password(client, hive_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:` access token from disk. Returns an error if it
|
|
/// is absent — callers should gate their homeserver calls on this.
|
|
pub fn read_hive_token() -> Result<String> {
|
|
let path = hive_token_path();
|
|
std::fs::read_to_string(&path)
|
|
.ok()
|
|
.map(|s| s.trim().to_owned())
|
|
.filter(|s| !s.is_empty())
|
|
.with_context(|| {
|
|
format!(
|
|
"the @hive: matrix access token was not found at {} — \
|
|
ensure hive-c0re has started at least once with matrix enabled \
|
|
(it provisions the @hive: 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, hive_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(hive_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(hive_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(hive_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, hive_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, hive_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(hive_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, hive_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(hive_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(hive_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(hive_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,
|
|
hive_token: &str,
|
|
url: &str,
|
|
) -> Option<serde_json::Value> {
|
|
let resp = match client.get(url).bearer_auth(hive_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 `@hive:` 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,
|
|
hive_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, hive_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(hive_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,
|
|
hive_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, hive_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(hive_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,
|
|
hive_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,
|
|
hive_token: &str,
|
|
room_id: &str,
|
|
localpart: &str,
|
|
server_name: &str,
|
|
) -> Result<()> {
|
|
let user_id = format!("@{localpart}:{server_name}");
|
|
invite_user_id(client, hive_token, room_id, &user_id).await
|
|
}
|
|
|
|
/// Fetch a user's current membership in a room via the `@hive:` 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,
|
|
hive_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(hive_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 `@hive:` 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,
|
|
hive_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, hive_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(hive_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 `@hive:` 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,
|
|
hive_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, hive_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, hive_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,
|
|
hive_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(hive_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;
|
|
// Loud and non-destructive: with no appservice token this sweep can
|
|
// create nothing, so it does nothing. Agents that already hold a
|
|
// token keep using it — their accounts and sessions are untouched by
|
|
// anything in here.
|
|
let as_token = match read_appservice_token() {
|
|
Ok(t) => t,
|
|
Err(e) => {
|
|
tracing::warn!(error = ?e, "matrix: no appservice token; skipping the user sweep");
|
|
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;
|
|
}
|
|
};
|
|
// The `@hive:` account first, because everything below provisions
|
|
// THROUGH it (the Space, the chat room and every invite are sent with
|
|
// its token) — as an ordinary user that created those rooms, not as a
|
|
// homeserver admin.
|
|
if let Err(e) = ensure_hive_user(&client, &as_token).await {
|
|
tracing::warn!(error = ?e, "matrix: ensure_hive_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, &as_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 hive_token = match read_hive_token() {
|
|
Ok(t) => t,
|
|
Err(e) => {
|
|
tracing::warn!(error = ?e, "matrix: skipping hive space provisioning (no @hive: access 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, &hive_token).await {
|
|
Ok(id) => id,
|
|
Err(e) => {
|
|
tracing::warn!(error = ?e, "matrix: ensure_hive_space failed");
|
|
return false;
|
|
}
|
|
};
|
|
// Invite @hive first, then all agents.
|
|
if let Err(e) =
|
|
invite_to_room(client, &hive_token, &room_id, HIVE_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, &hive_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, &hive_token, &room_id, &server_name).await {
|
|
Ok(chat_room_id) => {
|
|
if let Err(e) = invite_to_room(
|
|
client,
|
|
&hive_token,
|
|
&chat_room_id,
|
|
HIVE_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, &hive_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"));
|
|
}
|
|
}
|