diff --git a/hive-c0re/src/bin/hivectl.rs b/hive-c0re/src/bin/hivectl.rs index 52e4eafd..a3b8f3e5 100644 --- a/hive-c0re/src/bin/hivectl.rs +++ b/hive-c0re/src/bin/hivectl.rs @@ -478,6 +478,7 @@ async fn matrix_reset_password(name: &str) -> Result<()> { ); } let admin_token = hive_c0re::matrix::read_admin_token()?; + let new_password = hive_c0re::matrix::random_password().context("generate random password")?; let client = reqwest::Client::builder() .timeout(std::time::Duration::from_secs(30)) .build() @@ -485,10 +486,15 @@ async fn matrix_reset_password(name: &str) -> Result<()> { let server_name = hive_c0re::matrix::discover_server_name(&client) .await .context("discover matrix server_name")?; - hive_c0re::matrix::reset_user_password(&client, &admin_token, name, &server_name) - .await - .with_context(|| format!("matrix reset-password {name}"))?; - // Password is persisted by reset_user_password. + hive_c0re::matrix::reset_user_password( + &client, + &admin_token, + name, + &server_name, + &new_password, + ) + .await + .with_context(|| format!("matrix reset-password {name}"))?; 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 579ecf9c..03d94775 100644 --- a/hive-c0re/src/matrix.rs +++ b/hive-c0re/src/matrix.rs @@ -299,247 +299,12 @@ async fn auto_reset_password(client: &reqwest::Client, name: &str) -> anyhow::Re let server_name = discover_server_name(client) .await .context("matrix: discover_server_name for auto-recovery")?; - let effective_password = - reset_user_password(client, &admin_token, name, &server_name) - .await - .with_context(|| { - format!("matrix: admin-room password reset for {name} (auto-recovery)") - })?; - tracing::info!(%name, "matrix: auto-recovered password via admin-room reset"); - Ok(effective_password) -} - -// --------------------------------------------------------------------------- -// Admin-room fallback for password reset -// --------------------------------------------------------------------------- - -/// Percent-encode a matrix room ID for use in a URL path segment. -/// Only `:` needs encoding; `!` and alphanumerics are path-safe. -fn encode_room_id_for_url(room_id: &str) -> String { - room_id.replace(':', "%3A") -} - -/// Look up the room ID for the `#admins:` 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() + let new_password = random_password()?; + reset_user_password(client, &admin_token, name, &server_name, &new_password) .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: " -/// -/// Uses [`str::to_ascii_lowercase`] for case folding — unlike `to_lowercase`, -/// ASCII lowercasing is guaranteed to produce a same-byte-length string, so the -/// byte offset from `find` is always a valid index into the original `bot_message` -/// and we never slice at a non-char boundary. -fn extract_new_password(bot_message: &str) -> Option { - // ASCII lowercase: same byte length as the original, so positions from - // `lower.find(marker)` are valid byte indices into `bot_message`. - let lower = bot_message.to_ascii_lowercase(); - for marker in &[ - "new password is: ", - "new password is:", - "password is: ", - "password is:", - ] { - 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 -} - -#[cfg(test)] -mod extract_new_password_tests { - use super::extract_new_password; - - #[test] - fn tuwunel_style_response() { - let msg = "Done: Password of user @atlas:pr1ma.darkest.space has been reset. The new password is: abc123XYZ!"; - assert_eq!(extract_new_password(msg).as_deref(), Some("abc123XYZ!")); - } - - #[test] - fn case_insensitive_marker() { - let msg = "Password reset complete. New Password Is: S3cr3tP@ss"; - assert_eq!(extract_new_password(msg).as_deref(), Some("S3cr3tP@ss")); - } - - #[test] - fn marker_without_trailing_space() { - let msg = "new password is:hunter2"; - assert_eq!(extract_new_password(msg).as_deref(), Some("hunter2")); - } - - #[test] - fn shorter_marker_variant() { - let msg = "Your password is: Tr0ub4dor&3"; - assert_eq!(extract_new_password(msg).as_deref(), Some("Tr0ub4dor&3")); - } - - #[test] - fn no_match_returns_none() { - let msg = "Command not recognised. Please try again."; - assert_eq!(extract_new_password(msg), None); - } - - #[test] - fn empty_after_marker_returns_none() { - let msg = "new password is: "; - assert_eq!(extract_new_password(msg), None); - } - - #[test] - fn password_stops_at_whitespace() { - let msg = "New password is: abc123 (save it now)"; - assert_eq!(extract_new_password(msg).as_deref(), Some("abc123")); - } -} - -/// 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." - ) + .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) } /// Ensure `name` has a matrix user + token file on the local @@ -822,41 +587,45 @@ pub async fn promote_user_to_admin( ) } -/// Reset a user's password via the Matrix admin room (`#admins:`). -/// -/// Sends `reset-password @:` to the admin room as @hive, -/// polls for the bot's response containing the new password, and persists -/// it to the non-purgeable creds path so [`ensure_user_for`] can re-login -/// on the next provisioning sweep. -/// -/// Returns the new password for use in subsequent `login_user` calls. +/// Call `PUT /_synapse/admin/v2/users/@{localpart}:{server_name}` with +/// `{"password": new_password}` to reset a user's password. +/// Writes the new password to the non-purgeable creds path so +/// [`ensure_user_for`] can re-login on next provisioning sweep. pub async fn reset_user_password( client: &reqwest::Client, admin_token: &str, localpart: &str, server_name: &str, -) -> Result { - let pw = admin_room_reset_password(client, admin_token, server_name, localpart) + new_password: &str, +) -> Result<()> { + let url = format!("{MATRIX_HTTP}/_synapse/admin/v2/users/%40{localpart}%3A{server_name}"); + let resp = client + .put(&url) + .bearer_auth(admin_token) + .json(&serde_json::json!({"password": new_password})) + .send() .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)); + .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(()); } + 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" + ) } /// Discover the matrix `server_name` from the running homeserver via