From 2252eac65089a85ed87356512d7a5e21301b9451 Mon Sep 17 00:00:00 2001 From: atlas Date: Thu, 4 Jun 2026 15:17:20 +0200 Subject: [PATCH 1/5] fix: replace Synapse admin API in promote_user_to_admin with admin-room command --- hive-c0re/src/matrix.rs | 105 ++++++++++++++++++++++++++++++++++------ 1 file changed, 89 insertions(+), 16 deletions(-) diff --git a/hive-c0re/src/matrix.rs b/hive-c0re/src/matrix.rs index 29841e19..84d92b3e 100644 --- a/hive-c0re/src/matrix.rs +++ b/hive-c0re/src/matrix.rs @@ -794,33 +794,106 @@ 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:`). Sends `make-user-admin @:` to +/// the room as @hive, then polls for the bot's 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) + let room_id = discover_admin_room_id(client, admin_token, server_name).await?; + let room_url = encode_room_id_for_url(&room_id); + + // Anchor for forward-poll — grab current end-token before sending. + 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) - .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(()); + .context("matrix: admin room messages (back, promote)")? + .json::() + .await + .context("matrix: parse admin room messages response (promote)")?; + 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 promote 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!("make-user-admin @{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 (promote)")?; + if !send_resp.status().is_success() { + let body = send_resp + .json::() + .await + .unwrap_or_default(); + anyhow::bail!("matrix: admin room send failed (promote): {body}"); } - let body = resp.json::().await.unwrap_or_default(); + + // Poll for a bot "Done:" response (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 (promote)")? + .json::() + .await + .context("matrix: parse admin room poll response (promote)")?; + + 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; + } + if event["sender"].as_str() == Some(own_user_id.as_str()) { + continue; + } + let body = event["content"]["body"].as_str().unwrap_or("").to_ascii_lowercase(); + if body.starts_with("done") || body.contains("made") && body.contains("admin") { + tracing::info!(%localpart, attempt, "matrix: admin room make-user-admin succeeded"); + return Ok(()); + } + } + } + if let Some(end) = poll_json["end"].as_str() { + from_token = end.to_owned(); + } + } + 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" + "matrix: admin room make-user-admin for @{localpart}:{server_name}: no success \ + response within 5 seconds. Command sent: '{command}'. \ + Verify the admin room accepts 'make-user-admin @user:server' commands." ) } From e06d3e3a8b3e44bee01b04f0235673607f194d1b Mon Sep 17 00:00:00 2001 From: atlas Date: Thu, 4 Jun 2026 15:22:14 +0200 Subject: [PATCH 2/5] =?UTF-8?q?refactor:=20extract=20admin=5Froom=5Fsend?= =?UTF-8?q?=5Fand=5Fpoll=20helper=20=E2=80=94=20deduplicate=20poll=20loop?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- hive-c0re/src/matrix.rs | 192 ++++++++++++++-------------------------- 1 file changed, 67 insertions(+), 125 deletions(-) diff --git a/hive-c0re/src/matrix.rs b/hive-c0re/src/matrix.rs index 84d92b3e..3b629687 100644 --- a/hive-c0re/src/matrix.rs +++ b/hive-c0re/src/matrix.rs @@ -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 @:` to -/// the `#admins:` 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 5 × 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( 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); - - // 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, +) -> Result { + // 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::() - .await - .unwrap_or_default(); + let body = send_resp.json::().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:`). +/// Sends `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!("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 5 seconds. \ + Verify the admin room accepts '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). @@ -795,8 +807,8 @@ pub async fn ensure_admin_user(client: &reqwest::Client, register_token: &str) - } /// Promote a user to homeserver admin via the Matrix admin room -/// (`#admins:`). Sends `make-user-admin @:` to -/// the room as @hive, then polls for the bot's success response. +/// (`#admins:`). Sends `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. @@ -808,93 +820,23 @@ pub async fn promote_user_to_admin( ) -> Result<()> { let room_id = discover_admin_room_id(client, admin_token, server_name).await?; let room_url = encode_room_id_for_url(&room_id); - - // Anchor for forward-poll — grab current end-token before sending. - 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, promote)")? - .json::() - .await - .context("matrix: parse admin room messages response (promote)")?; - 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 promote 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!("make-user-admin @{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 (promote)")?; - if !send_resp.status().is_success() { - let body = send_resp - .json::() - .await - .unwrap_or_default(); - anyhow::bail!("matrix: admin room send failed (promote): {body}"); - } - - // Poll for a bot "Done:" response (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 (promote)")? - .json::() - .await - .context("matrix: parse admin room poll response (promote)")?; - - 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; - } - if event["sender"].as_str() == Some(own_user_id.as_str()) { - continue; - } - let body = event["content"]["body"].as_str().unwrap_or("").to_ascii_lowercase(); - if body.starts_with("done") || body.contains("made") && body.contains("admin") { - tracing::info!(%localpart, attempt, "matrix: admin room make-user-admin succeeded"); - return Ok(()); - } - } + 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 } - if let Some(end) = poll_json["end"].as_str() { - from_token = end.to_owned(); - } - } - - anyhow::bail!( - "matrix: admin room make-user-admin for @{localpart}:{server_name}: no success \ - response within 5 seconds. Command sent: '{command}'. \ - Verify the admin room accepts 'make-user-admin @user:server' commands." - ) + }) + .await + .with_context(|| { + format!( + "matrix: admin room make-user-admin for @{localpart}:{server_name}: \ + no success response within 5 seconds. \ + Verify the admin room accepts 'make-user-admin @user:server' commands." + ) + }) } /// Reset a user's password via the Matrix admin room (`#admins:`). From 0f505d5c95b4b22f6a47832e29a6723e01652524 Mon Sep 17 00:00:00 2001 From: atlas Date: Thu, 4 Jun 2026 15:39:10 +0200 Subject: [PATCH 3/5] fix: increase admin-room poll timeout from 5s to 15s MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- hive-c0re/src/matrix.rs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/hive-c0re/src/matrix.rs b/hive-c0re/src/matrix.rs index 3b629687..3a819c5b 100644 --- a/hive-c0re/src/matrix.rs +++ b/hive-c0re/src/matrix.rs @@ -434,7 +434,7 @@ mod extract_new_password_tests { /// /// 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 5 × 1 s for a bot message that satisfies `check`. +/// 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)`. /// @@ -550,7 +550,7 @@ async fn admin_room_reset_password( .with_context(|| { format!( "matrix: admin room reset-password for @{localpart}:{server_name}: \ - no password response received within 5 seconds. \ + no password response received within 15 seconds. \ Verify the admin room accepts 'reset-password @user:server' commands." ) }) @@ -833,7 +833,7 @@ pub async fn promote_user_to_admin( .with_context(|| { format!( "matrix: admin room make-user-admin for @{localpart}:{server_name}: \ - no success response within 5 seconds. \ + no success response within 15 seconds. \ Verify the admin room accepts 'make-user-admin @user:server' commands." ) }) From 85de83bd49e643c604719da992e5019e2f76dd61 Mon Sep 17 00:00:00 2001 From: atlas Date: Thu, 4 Jun 2026 16:35:59 +0200 Subject: [PATCH 4/5] fix: use correct tuwunel admin room command prefix for make-user-admin MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit tuwunel requires '!admin users ' — bare 'make-user-admin @user:server' is not recognised. Update command string and doc comments. --- hive-c0re/src/matrix.rs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/hive-c0re/src/matrix.rs b/hive-c0re/src/matrix.rs index 3a819c5b..2b99fa4d 100644 --- a/hive-c0re/src/matrix.rs +++ b/hive-c0re/src/matrix.rs @@ -807,7 +807,7 @@ pub async fn ensure_admin_user(client: &reqwest::Client, register_token: &str) - } /// Promote a user to homeserver admin via the Matrix admin room -/// (`#admins:`). Sends `make-user-admin @:` as +/// (`#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 @@ -820,7 +820,7 @@ pub async fn promote_user_to_admin( ) -> 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!("make-user-admin @{localpart}:{server_name}"); + 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") { @@ -834,7 +834,7 @@ pub async fn promote_user_to_admin( format!( "matrix: admin room make-user-admin for @{localpart}:{server_name}: \ no success response within 15 seconds. \ - Verify the admin room accepts 'make-user-admin @user:server' commands." + Verify the admin room accepts '!admin users make-user-admin @user:server' commands." ) }) } From 1fa398e99e4561d0191ac125cb7cb1281a80ecca Mon Sep 17 00:00:00 2001 From: atlas Date: Thu, 4 Jun 2026 16:43:53 +0200 Subject: [PATCH 5/5] fix: use correct tuwunel admin room command prefix for reset-password --- hive-c0re/src/matrix.rs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/hive-c0re/src/matrix.rs b/hive-c0re/src/matrix.rs index 2b99fa4d..0673f353 100644 --- a/hive-c0re/src/matrix.rs +++ b/hive-c0re/src/matrix.rs @@ -532,7 +532,7 @@ async fn admin_room_send_and_poll( } /// Reset a user's password via the Matrix admin room (`#admins:`). -/// Sends `reset-password @:` as @hive, polls for the bot's +/// 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. @@ -544,14 +544,14 @@ async fn admin_room_reset_password( ) -> 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!("reset-password @{localpart}:{server_name}"); + 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 'reset-password @user:server' commands." + Verify the admin room accepts '!admin users reset-password @user:server' commands." ) }) } @@ -841,7 +841,7 @@ pub async fn promote_user_to_admin( /// Reset a user's password via the Matrix admin room (`#admins:`). /// -/// Sends `reset-password @:` to the admin room as @hive, +/// Sends `!admin users 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.