Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a56badd004 | ||
|
|
97a78cc5d6 | ||
|
|
b2913bc656 |
2 changed files with 272 additions and 47 deletions
|
|
@ -478,7 +478,6 @@ async fn matrix_reset_password(name: &str) -> Result<()> {
|
|||
);
|
||||
}
|
||||
let admin_token = hive_c0re::matrix::read_admin_token()?;
|
||||
let new_password = hive_c0re::matrix::random_password().context("generate random password")?;
|
||||
let client = reqwest::Client::builder()
|
||||
.timeout(std::time::Duration::from_secs(30))
|
||||
.build()
|
||||
|
|
@ -486,15 +485,10 @@ async fn matrix_reset_password(name: &str) -> Result<()> {
|
|||
let server_name = hive_c0re::matrix::discover_server_name(&client)
|
||||
.await
|
||||
.context("discover matrix server_name")?;
|
||||
hive_c0re::matrix::reset_user_password(
|
||||
&client,
|
||||
&admin_token,
|
||||
name,
|
||||
&server_name,
|
||||
&new_password,
|
||||
)
|
||||
.await
|
||||
.with_context(|| format!("matrix reset-password {name}"))?;
|
||||
hive_c0re::matrix::reset_user_password(&client, &admin_token, name, &server_name)
|
||||
.await
|
||||
.with_context(|| format!("matrix reset-password {name}"))?;
|
||||
// Password is persisted by reset_user_password.
|
||||
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());
|
||||
|
|
|
|||
|
|
@ -299,12 +299,247 @@ async fn auto_reset_password(client: &reqwest::Client, name: &str) -> anyhow::Re
|
|||
let server_name = discover_server_name(client)
|
||||
.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)
|
||||
let effective_password =
|
||||
reset_user_password(client, &admin_token, name, &server_name)
|
||||
.await
|
||||
.with_context(|| {
|
||||
format!("matrix: admin-room password reset for {name} (auto-recovery)")
|
||||
})?;
|
||||
tracing::info!(%name, "matrix: auto-recovered password via admin-room reset");
|
||||
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
|
||||
.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)
|
||||
.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>"
|
||||
///
|
||||
/// 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`
|
||||
/// and we never slice at a non-char boundary.
|
||||
fn extract_new_password(bot_message: &str) -> Option<String> {
|
||||
// ASCII lowercase: same byte length as the original, so positions from
|
||||
// `lower.find(marker)` are valid byte indices into `bot_message`.
|
||||
let lower = bot_message.to_ascii_lowercase();
|
||||
for marker in &[
|
||||
"new password is: ",
|
||||
"new password is:",
|
||||
"password is: ",
|
||||
"password is:",
|
||||
] {
|
||||
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
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod extract_new_password_tests {
|
||||
use super::extract_new_password;
|
||||
|
||||
#[test]
|
||||
fn tuwunel_style_response() {
|
||||
let msg = "Done: Password of user @atlas:pr1ma.darkest.space has been reset. The new password is: abc123XYZ!";
|
||||
assert_eq!(extract_new_password(msg).as_deref(), Some("abc123XYZ!"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn case_insensitive_marker() {
|
||||
let msg = "Password reset complete. New Password Is: S3cr3tP@ss";
|
||||
assert_eq!(extract_new_password(msg).as_deref(), Some("S3cr3tP@ss"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn marker_without_trailing_space() {
|
||||
let msg = "new password is:hunter2";
|
||||
assert_eq!(extract_new_password(msg).as_deref(), Some("hunter2"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shorter_marker_variant() {
|
||||
let msg = "Your password is: Tr0ub4dor&3";
|
||||
assert_eq!(extract_new_password(msg).as_deref(), Some("Tr0ub4dor&3"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_match_returns_none() {
|
||||
let msg = "Command not recognised. Please try again.";
|
||||
assert_eq!(extract_new_password(msg), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_after_marker_returns_none() {
|
||||
let msg = "new password is: ";
|
||||
assert_eq!(extract_new_password(msg), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn password_stops_at_whitespace() {
|
||||
let msg = "New password is: abc123 (save it now)";
|
||||
assert_eq!(extract_new_password(msg).as_deref(), Some("abc123"));
|
||||
}
|
||||
}
|
||||
|
||||
/// 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,45 +822,41 @@ 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.
|
||||
/// Writes the new password to the non-purgeable creds path so
|
||||
/// [`ensure_user_for`] can re-login on next provisioning sweep.
|
||||
/// Reset a user's password via the Matrix admin room (`#admins:<server>`).
|
||||
///
|
||||
/// Sends `reset-password @<localpart>:<server>` 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.
|
||||
///
|
||||
/// Returns the new password for use in subsequent `login_user` calls.
|
||||
pub async fn reset_user_password(
|
||||
client: &reqwest::Client,
|
||||
admin_token: &str,
|
||||
localpart: &str,
|
||||
server_name: &str,
|
||||
new_password: &str,
|
||||
) -> Result<()> {
|
||||
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!({"password": new_password}))
|
||||
.send()
|
||||
) -> Result<String> {
|
||||
let pw = admin_room_reset_password(client, admin_token, server_name, localpart)
|
||||
.await
|
||||
.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(());
|
||||
.with_context(|| {
|
||||
format!("matrix: admin-room password reset for @{localpart}:{server_name}")
|
||||
})?;
|
||||
persist_password(localpart, &pw);
|
||||
Ok(pw)
|
||||
}
|
||||
|
||||
/// 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));
|
||||
}
|
||||
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"
|
||||
)
|
||||
}
|
||||
|
||||
/// Discover the matrix `server_name` from the running homeserver via
|
||||
|
|
|
|||
Loading…
Reference in a new issue