diff --git a/hive-c0re/src/matrix.rs b/hive-c0re/src/matrix.rs index 0673f353..29841e19 100644 --- a/hive-c0re/src/matrix.rs +++ b/hive-c0re/src/matrix.rs @@ -430,25 +430,25 @@ mod extract_new_password_tests { } } -/// Send a command to the Matrix admin room and poll for a bot response. +/// 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. /// -/// 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)`. -/// -/// Generic over `T` so both password-returning and `()` callers share the loop. -async fn admin_room_send_and_poll( +/// 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, - room_url: &str, - 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. + 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" ); @@ -470,11 +470,12 @@ async fn admin_room_send_and_poll( })? .to_owned(); - // Send the command. + // 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) @@ -483,14 +484,17 @@ async fn admin_room_send_and_poll( .await .context("matrix: PUT admin room message")?; if !send_resp.status().is_success() { - let body = send_resp.json::().await.unwrap_or_default(); + let body = send_resp + .json::() + .await + .unwrap_or_default(); anyhow::bail!("matrix: admin room send failed: {body}"); } - // Poll for bot response (up to 15 × 1 s). tuwunel can take several - // seconds to process admin-room commands during startup. - let own_user_id = format!("@{HIVE_ADMIN_LOCALPART}:{server_name}"); - for _ in 0..15_u8 { + // Poll for a bot response containing the new password (up to 15 × 1 s). + // tuwunel can take several seconds to process the admin-room command + // during startup when the homeserver is under load. + for attempt 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!( @@ -507,55 +511,39 @@ async fn admin_room_send_and_poll( .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(result) = check(body) { - return Ok(result); + 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 command timed out after 15 seconds. \ - Command: '{command}'. No matching bot response received." + "matrix: admin room reset-password for @{localpart}:{server_name}: no password \ + response received within 15 seconds. Command sent: '{command}'. \ + Verify the admin room accepts 'reset-password @user:server' commands." ) } -/// Reset a user's password via the Matrix admin room (`#admins:`). -/// Sends `!admin users reset-password @:` as @hive, polls for the bot's -/// response containing the new password. -/// -/// Returns the new password; caller is responsible for persisting it. -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); - let command = format!("!admin users reset-password @{localpart}:{server_name}"); - admin_room_send_and_poll(client, admin_token, server_name, &room_url, &command, extract_new_password) - .await - .with_context(|| { - format!( - "matrix: admin room reset-password for @{localpart}:{server_name}: \ - no password response received within 15 seconds. \ - Verify the admin room accepts '!admin users reset-password @user:server' commands." - ) - }) -} - /// Ensure `name` has a matrix user + token file on the local /// homeserver. Skips provisioning entirely if the token file already /// exists (treating a present token as proof the account is good). @@ -806,42 +794,39 @@ pub async fn ensure_admin_user(client: &reqwest::Client, register_token: &str) - Ok(()) } -/// Promote a user to homeserver admin via the Matrix admin room -/// (`#admins:`). Sends `!admin users make-user-admin @:` as -/// @hive, polls for the bot's "Done:" success response. -/// -/// tuwunel 1.6.x does not implement `/_synapse/admin/v2/users`; all admin -/// operations go through the admin room. +/// Call `PUT /_synapse/admin/v2/users/@{localpart}:{server_name}` with +/// `{"admin": true}` to promote a user to homeserver admin. +/// Requires the hive admin access token at [`admin_token_path()`]. pub async fn promote_user_to_admin( client: &reqwest::Client, admin_token: &str, localpart: &str, server_name: &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); - let command = format!("!admin users make-user-admin @{localpart}:{server_name}"); - admin_room_send_and_poll(client, admin_token, server_name, &room_url, &command, |body| { - let lower = body.to_ascii_lowercase(); - if lower.starts_with("done") || lower.contains("made") && lower.contains("admin") { - Some(()) - } else { - None - } - }) - .await - .with_context(|| { - format!( - "matrix: admin room make-user-admin for @{localpart}:{server_name}: \ - no success response within 15 seconds. \ - Verify the admin room accepts '!admin users make-user-admin @user:server' commands." - ) - }) + // URL-encode the @user:server path segment manually — only `@` and + // `:` need escaping; localpart + server_name use only safe chars. + 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!({"admin": true})) + .send() + .await + .context("matrix: PUT /_synapse/admin/v2/users (promote)")?; + let status = resp.status(); + if status.is_success() { + return Ok(()); + } + let body = resp.json::().await.unwrap_or_default(); + anyhow::bail!( + "matrix: promote @{localpart}:{server_name} to admin: HTTP {status}, body: {body}\n\ + note: tuwunel must implement /_synapse/admin/v2/users; if 404 use the conduit admin room instead" + ) } /// Reset a user's password via the Matrix admin room (`#admins:`). /// -/// Sends `!admin users reset-password @:` to the admin room as @hive, +/// 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.