fix: admin-room fallback for matrix password reset when Synapse API absent

tuwunel 1.6.x does not implement the Synapse admin REST API.
reset_user_password() now falls back to the Matrix admin room
(#admins:<server>) when PUT /_synapse/admin/v2/users returns 404:

1. Discover admin room ID via #admins:<server> alias
2. Get current messages end-token (pagination anchor)
3. Send 'reset-password @<localpart>:<server>' as @hive admin user
4. Poll for bot response up to 5 x 1s; extract password from message
5. Persist the new password and return it

The function signature changes from Result<()> to Result<String> so the
caller can use the effective password (which may be server-generated on
the admin-room path) for subsequent login calls.

Closes #1267.
This commit is contained in:
atlas 2026-06-04 14:58:20 +02:00 committed by mara
commit b2913bc656
2 changed files with 229 additions and 22 deletions

View file

@ -495,6 +495,7 @@ async fn matrix_reset_password(name: &str) -> Result<()> {
)
.await
.with_context(|| format!("matrix reset-password {name}"))?;
// Password is persisted by reset_user_password (including admin-room path).
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());

View file

@ -300,11 +300,194 @@ async fn auto_reset_password(client: &reqwest::Client, name: &str) -> anyhow::Re
.await
.context("matrix: discover_server_name for auto-recovery")?;
let new_password = random_password()?;
reset_user_password(client, &admin_token, name, &server_name, &new_password)
.await
.with_context(|| format!("matrix: admin API password reset for {name} (auto-recovery)"))?;
let effective_password =
reset_user_password(client, &admin_token, name, &server_name, &new_password)
.await
.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)
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:<server>` alias.
async fn discover_admin_room_id(
client: &reqwest::Client,
admin_token: &str,
server_name: &str,
) -> Result<String> {
// #admins:server → %23admins%3A<server>
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()
.await
.context("matrix: GET admin room alias")?;
let status = resp.status();
let json = resp
.json::<serde_json::Value>()
.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: <password>"
fn extract_new_password(bot_message: &str) -> Option<String> {
// Look for "new password is:" (case-insensitive) followed by whitespace + the password.
let lower = bot_message.to_lowercase();
for marker in &[
"new password is: ",
"new password is:",
"password is: ",
"password: ",
] {
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
}
/// 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 @<localpart>:<server>` to
/// the `#admins:<server>` 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<String> {
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::<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 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::<serde_json::Value>()
.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::<serde_json::Value>()
.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."
)
}
/// Ensure `name` has a matrix user + token file on the local
@ -587,17 +770,23 @@ pub async fn promote_user_to_admin(
)
}
/// Call `PUT /_synapse/admin/v2/users/@{localpart}:{server_name}` with
/// `{"password": new_password}` to reset a user's password.
/// Reset a user's password, trying the Synapse admin REST API first and
/// falling back to the Matrix admin room (`#admins:<server>`) if the endpoint
/// returns 404 (e.g. on tuwunel which does not implement the Synapse API).
///
/// Returns the effective new password — which may be the caller-supplied
/// `new_password` (Synapse path) or a server-generated one (admin-room path).
/// The caller must use the returned password for subsequent `login_user` calls.
///
/// Writes the new password to the non-purgeable creds path so
/// [`ensure_user_for`] can re-login on next provisioning sweep.
/// [`ensure_user_for`] can re-login on the next provisioning sweep.
pub async fn reset_user_password(
client: &reqwest::Client,
admin_token: &str,
localpart: &str,
server_name: &str,
new_password: &str,
) -> Result<()> {
) -> Result<String> {
let url = format!("{MATRIX_HTTP}/_synapse/admin/v2/users/%40{localpart}%3A{server_name}");
let resp = client
.put(&url)
@ -608,26 +797,43 @@ pub async fn reset_user_password(
.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(());
persist_password(localpart, new_password);
return Ok(new_password.to_owned());
}
// 404 = endpoint not implemented (e.g. tuwunel 1.6.x): fall back to admin room.
if status == StatusCode::NOT_FOUND {
tracing::debug!(
%localpart,
"matrix: Synapse admin API returned 404 — falling back to admin-room reset"
);
let pw = admin_room_reset_password(client, admin_token, server_name, localpart)
.await
.with_context(|| {
format!("matrix: admin-room fallback for reset password of @{localpart}:{server_name}")
})?;
persist_password(localpart, &pw);
return Ok(pw);
}
let body = resp.json::<serde_json::Value>().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"
"matrix: reset password for @{localpart}:{server_name}: HTTP {status}, body: {body}"
)
}
/// 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));
}
}
/// Discover the matrix `server_name` from the running homeserver via
/// `GET /_matrix/key/v2/server` (unauthenticated federation key endpoint).
/// The response JSON always includes `"server_name"` per the matrix spec.