From 254fd1f9f11a3c8e2341aef04a6df2b9032ea500 Mon Sep 17 00:00:00 2001 From: atlas Date: Thu, 4 Jun 2026 19:47:21 +0200 Subject: [PATCH] =?UTF-8?q?fix:=20admin-room=20poll=20strategy=20=E2=80=94?= =?UTF-8?q?=20backward=20fetch=20anchored=20on=20sent=20event=5Fid?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The forward-pagination approach (dir=b anchor → dir=f poll) fails in production: commands sent as @hive time out consistently even though tuwunel responds. Root cause is likely a pagination-token direction incompatibility in some tuwunel builds where 'end' from dir=b cannot be used as 'from' for dir=f. New strategy: send the command, capture the event_id from the PUT response, then poll dir=b&limit=20 each tick. Events come back newest-first; walk until we hit our own event_id, then stop — anything before that marker arrived after our command. Simpler, avoids stored tokens entirely. Also: - check formatted_body in addition to body (some admin bots put content only in HTML formatted_body) - add more extract_new_password patterns: 'changed to:', 'reset to:', 'set to:', 'new password:', 'password:' to handle different tuwunel version response formats - add unit tests for new patterns Fixes #1283. --- hive-c0re/src/matrix.rs | 125 +++++++++++++++++++++++++++------------- 1 file changed, 84 insertions(+), 41 deletions(-) diff --git a/hive-c0re/src/matrix.rs b/hive-c0re/src/matrix.rs index 0673f353..5de07c78 100644 --- a/hive-c0re/src/matrix.rs +++ b/hive-c0re/src/matrix.rs @@ -354,6 +354,9 @@ async fn discover_admin_room_id( /// Conduwuit/tuwunel responds with a message like: /// "Done: Password of user @user:server has been reset. The new password is: " /// +/// Handles several format variants emitted by different tuwunel / conduwuit +/// versions — "new password is:", "password is:", "changed to:", etc. +/// /// 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` @@ -363,14 +366,29 @@ fn extract_new_password(bot_message: &str) -> Option { // `lower.find(marker)` are valid byte indices into `bot_message`. let lower = bot_message.to_ascii_lowercase(); for marker in &[ + // Explicit "is:" variants (most common in conduwuit / tuwunel): "new password is: ", "new password is:", "password is: ", "password is:", + // "changed to:" / "reset to:" / "set to:" variants: + "changed to: ", + "changed to:", + "reset to: ", + "reset to:", + "set to: ", + "set to:", + // Bare "new password:" without "is": + "new password: ", + "new password:", + // Bare "password:" as last resort (must come after more specific markers): + "password: ", + "password:", ] { if let Some(pos) = lower.find(marker) { let rest = &bot_message[pos + marker.len()..]; let rest = rest.trim_start(); + // Stop at first whitespace or newline; password must be non-empty. let end = rest .find(|c: char| c.is_whitespace() || c == '\n') .unwrap_or(rest.len()); @@ -428,15 +446,44 @@ mod extract_new_password_tests { let msg = "New password is: abc123 (save it now)"; assert_eq!(extract_new_password(msg).as_deref(), Some("abc123")); } + + #[test] + fn changed_to_variant() { + let msg = "Password of user @atlas:pr1ma.darkest.space has been changed to: Xyz987!"; + assert_eq!(extract_new_password(msg).as_deref(), Some("Xyz987!")); + } + + #[test] + fn reset_to_variant() { + let msg = "Password for user @foo:bar has been reset to: hunter2"; + assert_eq!(extract_new_password(msg).as_deref(), Some("hunter2")); + } + + #[test] + fn bare_new_password_colon() { + let msg = "New password: P@ssword1"; + assert_eq!(extract_new_password(msg).as_deref(), Some("P@ssword1")); + } + + #[test] + fn bare_password_colon_last_resort() { + let msg = "Your account password: S3cr3t"; + assert_eq!(extract_new_password(msg).as_deref(), Some("S3cr3t")); + } } /// Send a command to the Matrix admin room and poll for a bot response. /// -/// 1. Anchors at the current end-token (`dir=b&limit=1`). -/// 2. Sends `command` as an `m.text` message from the @hive admin account. -/// 3. Polls up to 15 × 1 s for a bot message that satisfies `check`. -/// 4. Calls `check(body)` on each non-self message; returns `Ok(T)` on the -/// first `Some(T)`. +/// 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( @@ -447,30 +494,7 @@ async fn admin_room_send_and_poll( command: &str, check: impl Fn(&str) -> Option, ) -> Result { - // Pagination anchor — fetch the current tail so we only read events - // that arrive *after* our command, not old history. - 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 command. + // 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!( "{MATRIX_HTTP}/_matrix/client/v3/rooms/{room_url}/send/m.room.message/{txn_id}" @@ -486,16 +510,24 @@ async fn admin_room_send_and_poll( let body = send_resp.json::().await.unwrap_or_default(); anyhow::bail!("matrix: admin room send failed: {body}"); } + let send_json = send_resp + .json::() + .await + .unwrap_or_default(); + let our_event_id = send_json["event_id"] + .as_str() + .unwrap_or("") + .to_owned(); - // Poll for bot response (up to 15 × 1 s). tuwunel can take several - // seconds to process admin-room commands during startup. + // Poll for bot response: fetch the 20 most recent events (newest-first) + // on each tick. Walk the list until we hit our own command event_id; + // everything *before* that marker arrived after our command. let own_user_id = format!("@{HIVE_ADMIN_LOCALPART}:{server_name}"); + let poll_url = format!( + "{MATRIX_HTTP}/_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 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) @@ -508,21 +540,32 @@ async fn admin_room_send_and_poll( 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() + && 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; } - let body = event["content"]["body"].as_str().unwrap_or(""); - if let Some(result) = check(body) { - return Ok(result); + // 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); + } } } } - if let Some(end) = poll_json["end"].as_str() { - from_token = end.to_owned(); - } } anyhow::bail!(