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:
parent
9f8694e3cf
commit
254fd1f9f1
1 changed files with 84 additions and 41 deletions
|
|
@ -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!(
|
||||
|
|
|
|||
Loading…
Reference in a new issue