diff --git a/hive-c0re/src/bin/hivectl.rs b/hive-c0re/src/bin/hivectl.rs index a3b8f3e5..1c37f7b2 100644 --- a/hive-c0re/src/bin/hivectl.rs +++ b/hive-c0re/src/bin/hivectl.rs @@ -495,6 +495,7 @@ async fn matrix_reset_password(name: &str) -> Result<()> { ) .await .with_context(|| format!("matrix reset-password {name}"))?; + // Password is persisted by reset_user_password (including admin-room path). let pw_path = PathBuf::from("/var/lib/hyperhive/matrix-creds").join(format!("{name}-password")); println!("matrix: password for @{name}:{server_name} reset"); println!("password persisted at: {}", pw_path.display()); diff --git a/hive-c0re/src/matrix.rs b/hive-c0re/src/matrix.rs index 03d94775..d7ba9f95 100644 --- a/hive-c0re/src/matrix.rs +++ b/hive-c0re/src/matrix.rs @@ -300,11 +300,194 @@ async fn auto_reset_password(client: &reqwest::Client, name: &str) -> anyhow::Re .await .context("matrix: discover_server_name for auto-recovery")?; let new_password = random_password()?; - reset_user_password(client, &admin_token, name, &server_name, &new_password) - .await - .with_context(|| format!("matrix: admin API password reset for {name} (auto-recovery)"))?; + let effective_password = + reset_user_password(client, &admin_token, name, &server_name, &new_password) + .await + .with_context(|| { + format!("matrix: admin API password reset for {name} (auto-recovery)") + })?; tracing::info!(%name, "matrix: auto-recovered password via admin API reset"); - Ok(new_password) + Ok(effective_password) +} + +// --------------------------------------------------------------------------- +// Admin-room fallback for password reset +// --------------------------------------------------------------------------- + +/// Percent-encode a matrix room ID for use in a URL path segment. +/// Only `:` needs encoding; `!` and alphanumerics are path-safe. +fn encode_room_id_for_url(room_id: &str) -> String { + room_id.replace(':', "%3A") +} + +/// Look up the room ID for the `#admins:` alias. +async fn discover_admin_room_id( + client: &reqwest::Client, + admin_token: &str, + server_name: &str, +) -> Result { + // #admins:server → %23admins%3A + let encoded_alias = format!("%23admins%3A{server_name}"); + let url = format!("{MATRIX_HTTP}/_matrix/client/v3/directory/room/{encoded_alias}"); + let resp = client + .get(&url) + .bearer_auth(admin_token) + .send() + .await + .context("matrix: GET admin room alias")?; + let status = resp.status(); + let json = resp + .json::() + .await + .context("matrix: parse admin room alias response")?; + if !status.is_success() { + anyhow::bail!("matrix: admin room alias lookup failed: HTTP {status}, body: {json}"); + } + json["room_id"] + .as_str() + .map(|s| s.to_owned()) + .ok_or_else(|| { + anyhow::anyhow!("matrix: admin room alias response missing room_id: {json}") + }) +} + +/// Try to parse the new password from an admin-room bot response. +/// Conduwuit/tuwunel responds with a message like: +/// "Done: Password of user @user:server has been reset. The new password is: " +fn extract_new_password(bot_message: &str) -> Option { + // Look for "new password is:" (case-insensitive) followed by whitespace + the password. + let lower = bot_message.to_lowercase(); + for marker in &[ + "new password is: ", + "new password is:", + "password is: ", + "password: ", + ] { + if let Some(pos) = lower.find(marker) { + let rest = &bot_message[pos + marker.len()..]; + let rest = rest.trim_start(); + let end = rest + .find(|c: char| c.is_whitespace() || c == '\n') + .unwrap_or(rest.len()); + let pw = rest[..end].trim(); + if !pw.is_empty() { + return Some(pw.to_owned()); + } + } + } + None +} + +/// Reset a user's password via the Matrix admin room as a fallback for +/// homeservers that do not implement the Synapse admin REST API (e.g. +/// tuwunel 1.6.x). Sends `reset-password @:` to +/// the `#admins:` room as @hive, then polls for the bot's +/// response message containing the new password. +/// +/// Returns the new password on success; the caller is responsible for +/// persisting it to [`password_path`]. +async fn admin_room_reset_password( + client: &reqwest::Client, + admin_token: &str, + server_name: &str, + localpart: &str, +) -> Result { + let room_id = discover_admin_room_id(client, admin_token, server_name).await?; + let room_url = encode_room_id_for_url(&room_id); + + // Get the current messages end-token so we can poll forward for the + // bot's response without re-reading old messages. + let msgs_back_url = format!( + "{MATRIX_HTTP}/_matrix/client/v3/rooms/{room_url}/messages?dir=b&limit=1" + ); + let msgs_back = client + .get(&msgs_back_url) + .bearer_auth(admin_token) + .send() + .await + .context("matrix: admin room messages (back)")? + .json::() + .await + .context("matrix: parse admin room messages response")?; + let mut from_token = msgs_back["end"] + .as_str() + .ok_or_else(|| { + anyhow::anyhow!( + "matrix: admin room messages response missing 'end' token: {msgs_back}" + ) + })? + .to_owned(); + + // Send the password-reset command. + let txn_id = random_hex(8)?; + let send_url = format!( + "{MATRIX_HTTP}/_matrix/client/v3/rooms/{room_url}/send/m.room.message/{txn_id}" + ); + let command = format!("reset-password @{localpart}:{server_name}"); + let send_resp = client + .put(&send_url) + .bearer_auth(admin_token) + .json(&serde_json::json!({"msgtype": "m.text", "body": command})) + .send() + .await + .context("matrix: PUT admin room message")?; + if !send_resp.status().is_success() { + let body = send_resp + .json::() + .await + .unwrap_or_default(); + anyhow::bail!("matrix: admin room send failed: {body}"); + } + + // Poll for a bot response containing the new password (up to 5 × 1 s). + for attempt in 0..5_u8 { + tokio::time::sleep(std::time::Duration::from_secs(1)).await; + let from_encoded = from_token.replace(':', "%3A").replace('+', "%2B"); + let poll_url = format!( + "{MATRIX_HTTP}/_matrix/client/v3/rooms/{room_url}/messages?dir=f&from={from_encoded}&limit=10" + ); + let poll_json = client + .get(&poll_url) + .bearer_auth(admin_token) + .send() + .await + .context("matrix: admin room poll")? + .json::() + .await + .context("matrix: parse admin room poll response")?; + + if let Some(events) = poll_json["chunk"].as_array() { + let own_user_id = format!("@{HIVE_ADMIN_LOCALPART}:{server_name}"); + for event in events { + if event["type"].as_str() != Some("m.room.message") { + continue; + } + // Skip our own command message. + if event["sender"].as_str() == Some(own_user_id.as_str()) { + continue; + } + let body = event["content"]["body"].as_str().unwrap_or(""); + if let Some(pw) = extract_new_password(body) { + tracing::info!( + %localpart, + attempt, + "matrix: admin room reset-password response parsed" + ); + return Ok(pw); + } + } + } + // Advance the pagination token so we don't re-read the same events. + if let Some(end) = poll_json["end"].as_str() { + from_token = end.to_owned(); + } + } + + anyhow::bail!( + "matrix: admin room reset-password for @{localpart}:{server_name}: no password \ + response received within 5 seconds. Command sent: '{command}'. \ + Verify the admin room accepts 'reset-password @user:server' commands." + ) } /// Ensure `name` has a matrix user + token file on the local @@ -587,17 +770,23 @@ pub async fn promote_user_to_admin( ) } -/// Call `PUT /_synapse/admin/v2/users/@{localpart}:{server_name}` with -/// `{"password": new_password}` to reset a user's password. +/// Reset a user's password, trying the Synapse admin REST API first and +/// falling back to the Matrix admin room (`#admins:`) if the endpoint +/// returns 404 (e.g. on tuwunel which does not implement the Synapse API). +/// +/// Returns the effective new password — which may be the caller-supplied +/// `new_password` (Synapse path) or a server-generated one (admin-room path). +/// The caller must use the returned password for subsequent `login_user` calls. +/// /// Writes the new password to the non-purgeable creds path so -/// [`ensure_user_for`] can re-login on next provisioning sweep. +/// [`ensure_user_for`] can re-login on the next provisioning sweep. pub async fn reset_user_password( client: &reqwest::Client, admin_token: &str, localpart: &str, server_name: &str, new_password: &str, -) -> Result<()> { +) -> Result { let url = format!("{MATRIX_HTTP}/_synapse/admin/v2/users/%40{localpart}%3A{server_name}"); let resp = client .put(&url) @@ -608,26 +797,43 @@ pub async fn reset_user_password( .context("matrix: PUT /_synapse/admin/v2/users (reset password)")?; let status = resp.status(); if status.is_success() { - // Persist the new password so ensure_user_for can re-login. - 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!("{new_password}\n")) { - tracing::warn!(%localpart, error = ?e, "matrix: failed to persist reset password"); - } else { - use std::os::unix::fs::PermissionsExt; - let _ = std::fs::set_permissions(&pw_path, std::fs::Permissions::from_mode(0o600)); - } - return Ok(()); + persist_password(localpart, new_password); + return Ok(new_password.to_owned()); + } + // 404 = endpoint not implemented (e.g. tuwunel 1.6.x): fall back to admin room. + if status == StatusCode::NOT_FOUND { + tracing::debug!( + %localpart, + "matrix: Synapse admin API returned 404 — falling back to admin-room reset" + ); + let pw = admin_room_reset_password(client, admin_token, server_name, localpart) + .await + .with_context(|| { + format!("matrix: admin-room fallback for reset password of @{localpart}:{server_name}") + })?; + persist_password(localpart, &pw); + return Ok(pw); } let body = resp.json::().await.unwrap_or_default(); anyhow::bail!( - "matrix: reset password for @{localpart}:{server_name}: HTTP {status}, body: {body}\n\ - note: tuwunel must implement /_synapse/admin/v2/users; if 404 use the conduit admin room instead" + "matrix: reset password for @{localpart}:{server_name}: HTTP {status}, body: {body}" ) } +/// 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.