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." ) }