hivectl, hive-c0re: remove dead matrix create-user/promote-user/reset-password
Human matrix accounts come from SSO, not hivectl. Matrix homeserver admin will come from authelia's admins group (sync tracked in #4585); password reset moves to swarm level (#4798). promote-user and reset-password were already broken from the hive: the hive's sender account has no admin sender to call the admin room with, only the swarm's does. Removes the three hivectl matrix verbs, their HostRequest variants, their hive-c0re handlers, and the admin-room helpers (discover room id, send-and-poll, event-id extraction, password/success parsing) that only they used. sync-admin and invite are unchanged. Refs #4585
This commit is contained in:
parent
69ae23f801
commit
93bbec015f
12 changed files with 37 additions and 748 deletions
|
|
@ -124,8 +124,7 @@ pub fn sender_token_path() -> PathBuf {
|
|||
crate::paths::matrix_sender_token()
|
||||
}
|
||||
|
||||
/// Password file for a matrix account this hive holds a password for: its own
|
||||
/// `@hive-<hive>:` account, or one reset through the admin room. Stored OUTSIDE
|
||||
/// Password file for the hive's own `@hive-<hive>:` account. Stored OUTSIDE
|
||||
/// the purgeable `agent_state_root` tree so it survives `destroy --purge`.
|
||||
///
|
||||
/// Path: `/var/lib/hyperhive/matrix/creds/<name>-password`
|
||||
|
|
@ -244,11 +243,9 @@ async fn register_post(
|
|||
}
|
||||
|
||||
/// 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`).
|
||||
/// `PASSWORD_BYTES` raw bytes ⇒ 64-char hex string. The hive sender
|
||||
/// account authenticates by `access_token`, so this password is
|
||||
/// protocol overhead never used for login.
|
||||
pub fn random_password() -> Result<String> {
|
||||
random_hex(PASSWORD_BYTES)
|
||||
}
|
||||
|
|
@ -263,9 +260,9 @@ pub fn random_password() -> Result<String> {
|
|||
/// 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.
|
||||
/// Its one caller, the hive sender account's provisioning, always passes
|
||||
/// a [`random_password`] throwaway — the account authenticates by
|
||||
/// `access_token`, never `m.login.password`.
|
||||
///
|
||||
/// # Errors
|
||||
/// Propagates the homeserver's own body, which is what the
|
||||
|
|
@ -351,9 +348,9 @@ fn extract_access_token(body: &serde_json::Value) -> Result<String> {
|
|||
/// 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.
|
||||
/// if the stored password no longer matches (e.g. homeserver wiped);
|
||||
/// the only caller is the hive sender account's own recovery path
|
||||
/// (`hivectl matrix sync-admin`).
|
||||
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");
|
||||
|
|
@ -384,303 +381,12 @@ async fn login_user(client: &reqwest::Client, agent: &str, password: &str) -> Re
|
|||
extract_access_token(&json)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 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,
|
||||
sender_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(sender_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);
|
||||
}
|
||||
}
|
||||
|
||||
/// The `event_id` of an admin-room command, from its `PUT .../send`
|
||||
/// response. It is the anchor separating the bot's reply to this command
|
||||
/// from older replies in the room, so a response without one is an error:
|
||||
/// unanchored, an earlier reply (an older reset password) would be returned
|
||||
/// as this command's result.
|
||||
async fn sent_event_id(resp: reqwest::Response) -> Result<String> {
|
||||
let status = resp.status();
|
||||
if !status.is_success() {
|
||||
let body = resp.json::<serde_json::Value>().await.unwrap_or_default();
|
||||
anyhow::bail!("matrix: admin room send failed: HTTP {status}, body: {body}");
|
||||
}
|
||||
let body = resp
|
||||
.json::<serde_json::Value>()
|
||||
.await
|
||||
.context("matrix: parse admin room send response")?;
|
||||
body["event_id"]
|
||||
.as_str()
|
||||
.filter(|id| !id.is_empty())
|
||||
.map(str::to_owned)
|
||||
.with_context(|| format!("matrix: admin room send response has no event_id: {body}"))
|
||||
}
|
||||
|
||||
/// 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,
|
||||
sender_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(sender_token)
|
||||
.json(&serde_json::json!({"msgtype": "m.text", "body": command}))
|
||||
.send()
|
||||
.await
|
||||
.context("matrix: PUT admin room message")?;
|
||||
let our_event_id = sent_event_id(send_resp).await?;
|
||||
|
||||
// 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!("@{}:{server_name}", hive_localpart()?);
|
||||
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(sender_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 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-<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,
|
||||
sender_token: &str,
|
||||
server_name: &str,
|
||||
localpart: &str,
|
||||
) -> Result<String> {
|
||||
let room_id = discover_admin_room_id(client, sender_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,
|
||||
sender_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."
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
/// Register a matrix account for `name` with the supplied `password`
|
||||
/// and return the freshly-minted access token. 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. For operator accounts the caller passes a real
|
||||
/// password so the operator can `m.login.password` into matrix web
|
||||
/// clients afterwards; without one the caller passes
|
||||
/// [`random_password`].
|
||||
///
|
||||
/// **Not idempotent**: 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
|
||||
}
|
||||
|
||||
/// Ensure the hive's `@hive-<hive>:` matrix user exists and that its access token is
|
||||
/// persisted at [`sender_token_path()`].
|
||||
///
|
||||
|
|
@ -843,141 +549,6 @@ async fn stored_sender_token() -> Option<String> {
|
|||
}
|
||||
}
|
||||
|
||||
/// 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-<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-<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,
|
||||
sender_token: &str,
|
||||
localpart: &str,
|
||||
server_name: &str,
|
||||
) -> Result<()> {
|
||||
let room_id = discover_admin_room_id(client, sender_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,
|
||||
sender_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-<hive>:`, polls for the bot's response containing the new password, and persists
|
||||
/// it to the non-purgeable creds path.
|
||||
///
|
||||
/// ⚠️ Same admin-**sender** requirement as [`promote_user_to_admin`], and
|
||||
/// the same consequence: `@hive-<hive>:` is an ordinary account with no admin
|
||||
/// sender, and reset, like promotion, is a swarm-level operation rehomed
|
||||
/// to the swarm tier rather than granted here — so it has no working
|
||||
/// sender from the hive either.
|
||||
///
|
||||
/// Returns the new password for use in subsequent `login_user` calls.
|
||||
pub async fn reset_user_password(
|
||||
client: &reqwest::Client,
|
||||
sender_token: &str,
|
||||
localpart: &str,
|
||||
server_name: &str,
|
||||
) -> Result<String> {
|
||||
let pw = admin_room_reset_password(client, sender_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.
|
||||
|
|
@ -1915,27 +1486,6 @@ mod tests {
|
|||
assert!(outcome.is_err(), "a 500 is not excused by membership");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn an_admin_send_without_an_event_id_is_an_error() {
|
||||
for (status, body) in [
|
||||
(500, r#"{"event_id":"$e"}"#),
|
||||
(200, "not json"),
|
||||
(200, "{}"),
|
||||
(200, r#"{"event_id":""}"#),
|
||||
] {
|
||||
let outcome = sent_event_id(response(status, body)).await;
|
||||
assert!(outcome.is_err(), "HTTP {status} {body:?} must fail");
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn an_admin_send_returns_its_event_id() {
|
||||
let id = sent_event_id(response(200, r#"{"event_id":"$e"}"#))
|
||||
.await
|
||||
.expect("well-formed send response");
|
||||
assert_eq!(id, "$e");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_access_token_errors_on_missing_field() {
|
||||
let body = serde_json::json!({"user_id": "@alice:matrix.example.org"});
|
||||
|
|
|
|||
|
|
@ -133,9 +133,9 @@ pub fn agent_identity_dir(name: &str) -> PathBuf {
|
|||
}
|
||||
|
||||
/// `matrix/` — host-side matrix provisioning state (the appservice sender token, hive
|
||||
/// Space room id, per-agent password creds). The shared registration
|
||||
/// token is bind-mounted into the tuwunel container via nix and stays
|
||||
/// at its own path (tracked separately).
|
||||
/// Space room id, the hive sender account's password creds). The shared
|
||||
/// registration token is bind-mounted into the tuwunel container via nix
|
||||
/// and stays at its own path (tracked separately).
|
||||
#[must_use]
|
||||
pub fn matrix_dir() -> PathBuf {
|
||||
state_root().join("matrix")
|
||||
|
|
@ -160,8 +160,9 @@ pub fn matrix_chat_room_id() -> PathBuf {
|
|||
matrix_dir().join("chat-room-id")
|
||||
}
|
||||
|
||||
/// `matrix/creds/` — per-agent throwaway matrix passwords (survive
|
||||
/// `destroy --purge`; agents auth by token, this is recovery only).
|
||||
/// `matrix/creds/` — the hive sender account's throwaway matrix password
|
||||
/// (survives `destroy --purge`; it authenticates by token, this is
|
||||
/// recovery only).
|
||||
#[must_use]
|
||||
pub fn matrix_creds_dir() -> PathBuf {
|
||||
matrix_dir().join("creds")
|
||||
|
|
|
|||
|
|
@ -220,16 +220,7 @@ async fn dispatch(req: &HostRequest, coord: Arc<Coordinator>) -> HostResponse {
|
|||
)
|
||||
.await?
|
||||
}
|
||||
HostRequest::MatrixCreateUser { name, password } => {
|
||||
handle_matrix_create_user(name, password.as_deref()).await?
|
||||
}
|
||||
HostRequest::MatrixSyncAdmin => handle_matrix_sync_admin().await?,
|
||||
HostRequest::MatrixPromoteUser { name } => {
|
||||
handle_matrix_promote_user(name.as_str()).await?
|
||||
}
|
||||
HostRequest::MatrixResetPassword { name } => {
|
||||
handle_matrix_reset_password(name.as_str()).await?
|
||||
}
|
||||
HostRequest::MatrixInvite { user, room } => {
|
||||
handle_matrix_invite(user, room.as_deref()).await?
|
||||
}
|
||||
|
|
@ -536,46 +527,6 @@ fn require_matrix_present() -> Result<()> {
|
|||
)
|
||||
}
|
||||
|
||||
async fn handle_matrix_create_user(
|
||||
name: &hive_types::Ident,
|
||||
password: Option<&str>,
|
||||
) -> Result<HostResponse> {
|
||||
require_matrix_present()?;
|
||||
if agent_exists(name)? {
|
||||
// The swarm mints an agent's account and stores its token where the
|
||||
// agent reads it; a second minter here would replace that token on the
|
||||
// same device.
|
||||
anyhow::bail!(
|
||||
"matrix create-user: '{name}' is an agent, and an agent's matrix account comes from \
|
||||
the swarm: swarm-controller creates it and re-checks it every five minutes"
|
||||
);
|
||||
}
|
||||
let as_token =
|
||||
crate::matrix::read_appservice_token().context("read matrix appservice token")?;
|
||||
let client = matrix_http_client()?;
|
||||
let mut out = Vec::new();
|
||||
let effective_password = match password {
|
||||
Some(p) => p.to_owned(),
|
||||
None => crate::matrix::random_password().context("generate random matrix password")?,
|
||||
};
|
||||
let token =
|
||||
crate::matrix::provision_user_token(&client, name.as_str(), &as_token, &effective_password)
|
||||
.await
|
||||
.with_context(|| format!("matrix create-user {name}"))?;
|
||||
out.push(format!(
|
||||
"matrix: provisioned user '{name}' (not an agent — token not persisted)"
|
||||
));
|
||||
out.push(format!("token: {token}"));
|
||||
if password.is_some() {
|
||||
out.push("password: set as supplied — use it to log into a matrix web client".to_owned());
|
||||
} else {
|
||||
out.push(
|
||||
"password: random throwaway (not surfaced — pass --password or --password-stdin to set one you can use)".to_owned(),
|
||||
);
|
||||
}
|
||||
Ok(HostResponse::messages(out))
|
||||
}
|
||||
|
||||
async fn handle_set_agent_github_token(agent: &str, token: &str) -> Result<HostResponse> {
|
||||
crate::priv_client::write_agent_github_token(agent, token)
|
||||
.await
|
||||
|
|
@ -712,21 +663,6 @@ async fn handle_matrix_sync_admin() -> Result<HostResponse> {
|
|||
]))
|
||||
}
|
||||
|
||||
async fn handle_matrix_promote_user(name: &str) -> Result<HostResponse> {
|
||||
require_matrix_present()?;
|
||||
let sender_token = crate::matrix::read_sender_token()?;
|
||||
let client = matrix_http_client()?;
|
||||
let server_name = crate::matrix::discover_server_name(&client)
|
||||
.await
|
||||
.context("discover matrix server_name")?;
|
||||
crate::matrix::promote_user_to_admin(&client, &sender_token, name, &server_name)
|
||||
.await
|
||||
.with_context(|| format!("matrix promote-user {name}"))?;
|
||||
Ok(HostResponse::messages(vec![format!(
|
||||
"matrix: promoted @{name}:{server_name} to admin"
|
||||
)]))
|
||||
}
|
||||
|
||||
async fn handle_matrix_invite(user: &str, room: Option<&str>) -> Result<HostResponse> {
|
||||
require_matrix_present()?;
|
||||
let sender_token = crate::matrix::read_sender_token()?;
|
||||
|
|
@ -747,24 +683,6 @@ async fn handle_matrix_invite(user: &str, room: Option<&str>) -> Result<HostResp
|
|||
)]))
|
||||
}
|
||||
|
||||
async fn handle_matrix_reset_password(name: &str) -> Result<HostResponse> {
|
||||
require_matrix_present()?;
|
||||
let sender_token = crate::matrix::read_sender_token()?;
|
||||
let client = matrix_http_client()?;
|
||||
let server_name = crate::matrix::discover_server_name(&client)
|
||||
.await
|
||||
.context("discover matrix server_name")?;
|
||||
crate::matrix::reset_user_password(&client, &sender_token, name, &server_name)
|
||||
.await
|
||||
.with_context(|| format!("matrix reset-password {name}"))?;
|
||||
// Password is persisted by reset_user_password.
|
||||
let pw_path = crate::paths::matrix_creds_dir().join(format!("{name}-password"));
|
||||
Ok(HostResponse::messages(vec![
|
||||
format!("matrix: password for @{name}:{server_name} reset"),
|
||||
format!("password persisted at: {}", pw_path.display()),
|
||||
]))
|
||||
}
|
||||
|
||||
/// Single-agent queue verbs the admin socket exposes. Each submits the
|
||||
/// matching DAG (persisting the `wanted` intent, serializing on the
|
||||
/// agent's lease, with the transient/crash-watch suppression the old
|
||||
|
|
|
|||
Loading…
Reference in a new issue