Compare commits

...
Author SHA1 Message Date
atlas
1fa398e99e fix: use correct tuwunel admin room command prefix for reset-password 2026-06-04 16:43:53 +02:00
atlas
85de83bd49 fix: use correct tuwunel admin room command prefix for make-user-admin
tuwunel requires '!admin users <cmd>' — bare 'make-user-admin @user:server'
is not recognised. Update command string and doc comments.
2026-06-04 16:35:59 +02:00
atlas
0f505d5c95 fix: increase admin-room poll timeout from 5s to 15s
tuwunel can take longer than 5 seconds to process admin-room commands
during startup when the homeserver is under load. Bump both the poll
count (5→15) and the timeout message strings to match.
2026-06-04 16:28:47 +02:00
atlas
e06d3e3a8b refactor: extract admin_room_send_and_poll helper — deduplicate poll loop 2026-06-04 16:28:47 +02:00
atlas
2252eac650 fix: replace Synapse admin API in promote_user_to_admin with admin-room command 2026-06-04 16:26:59 +02:00

View file

@ -430,25 +430,25 @@ mod extract_new_password_tests {
}
}
/// 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.
/// Send a command to the Matrix admin room and poll for a bot response.
///
/// Returns the new password on success; the caller is responsible for
/// persisting it to [`password_path`].
async fn admin_room_reset_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<T>(
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.
room_url: &str,
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"
);
@ -470,12 +470,11 @@ async fn admin_room_reset_password(
})?
.to_owned();
// Send the password-reset command.
// Send the 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)
@ -484,17 +483,14 @@ async fn admin_room_reset_password(
.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();
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 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 {
// 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 {
tokio::time::sleep(std::time::Duration::from_secs(1)).await;
let from_encoded = from_token.replace(':', "%3A").replace('+', "%2B");
let poll_url = format!(
@ -511,39 +507,55 @@ async fn admin_room_reset_password(
.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);
if let Some(result) = check(body) {
return Ok(result);
}
}
}
// 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 15 seconds. Command sent: '{command}'. \
Verify the admin room accepts 'reset-password @user:server' commands."
"matrix: admin room command timed out after 15 seconds. \
Command: '{command}'. No matching bot response received."
)
}
/// Reset a user's password via the Matrix admin room (`#admins:<server>`).
/// Sends `!admin users reset-password @<localpart>:<server>` 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<String> {
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).
@ -794,39 +806,42 @@ pub async fn ensure_admin_user(client: &reqwest::Client, register_token: &str) -
Ok(())
}
/// 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()`].
/// Promote a user to homeserver admin via the Matrix admin room
/// (`#admins:<server>`). Sends `!admin users make-user-admin @<localpart>:<server>` 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.
pub async fn promote_user_to_admin(
client: &reqwest::Client,
admin_token: &str,
localpart: &str,
server_name: &str,
) -> Result<()> {
// 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::<serde_json::Value>().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"
)
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."
)
})
}
/// Reset a user's password via the Matrix admin room (`#admins:<server>`).
///
/// Sends `reset-password @<localpart>:<server>` to the admin room as @hive,
/// Sends `!admin users 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.