fix: admin-room poll strategy — backward fetch anchored on sent event_id

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.
This commit is contained in:
atlas 2026-06-04 19:47:21 +02:00 committed by mara
commit 254fd1f9f1

View file

@ -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: <password>"
///
/// 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<String> {
// `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<T>(
@ -447,30 +494,7 @@ async fn admin_room_send_and_poll<T>(
command: &str,
check: impl Fn(&str) -> Option<T>,
) -> Result<T> {
// 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::<serde_json::Value>()
.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<T>(
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 (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<T>(
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!(