diff --git a/src/config.rs b/src/config.rs index c980c26b..16cf9478 100644 --- a/src/config.rs +++ b/src/config.rs @@ -10,7 +10,7 @@ use once_cell::sync::Lazy; use reqwest::Url; use ruma::{ api::federation::discovery::OldVerifyKey, OwnedServerName, - OwnedServerSigningKeyId, RoomVersionId, + OwnedServerSigningKeyId, RoomVersionId, UserId, }; use serde::Deserialize; use strum::{Display, EnumIter, IntoEnumIterator}; @@ -73,6 +73,7 @@ pub(crate) struct Config { #[serde(default)] pub(crate) turn: TurnConfig, + pub(crate) admin_bot_localpart: String, pub(crate) emergency_password: Option, } @@ -451,5 +452,15 @@ where return Err(Error::RegistrationTokenEmpty); } + let admin_bot_id = UserId::parse(format!( + "@{}:{}", + config.admin_bot_localpart, config.server_name + )) + .map_err(Error::InvalidAdminBotId)?; + + if admin_bot_id.is_historical() { + return Err(Error::HistoricalAdminBotLocalpart); + } + Ok(config) } diff --git a/src/database.rs b/src/database.rs index 7483c93c..034887eb 100644 --- a/src/database.rs +++ b/src/database.rs @@ -1089,6 +1089,21 @@ impl KeyValueDatabase { version = latest_database_version, "Loaded database", ); + + // TODO: Return this error directly instead of map_err hacks + services().admin.apply_admin_bot_localpart().await.map_err( + |e| { + error!( + error = %crate::error::DisplayWithSources{ + error: &e, + infix: ": ", + }, + "failed to apply admin bot localpart" + ); + + Error::BadConfig("failed to apply admin bot localpart") + }, + )?; } else { services() .globals diff --git a/src/error.rs b/src/error.rs index 500a09ca..02038941 100644 --- a/src/error.rs +++ b/src/error.rs @@ -136,6 +136,14 @@ pub(crate) enum Config { #[error("registration token must not be empty")] RegistrationTokenEmpty, + + #[error("invalid admin bot ID")] + InvalidAdminBotId(#[source] ruma::IdParseError), + + #[error( + "admin bot ID is historical (i.e. contains deprecated characters)" + )] + HistoricalAdminBotLocalpart, } /// Errors that can occur while searching for a config file @@ -183,3 +191,50 @@ pub(crate) enum Serve { )] FederationSelfTestFailed(#[source] crate::Error), } + +/// Errors applying the admin bot user localpart +// Missing docs are allowed here since that kind of information should be +// encoded in the error messages themselves anyway. +#[allow(missing_docs)] +#[derive(Error, Debug)] +pub(crate) enum ApplyAdminBotLocalpart { + #[error("failed to resolve the admin room ID")] + ResolveAdminRoomId(#[source] crate::utils::error::Error), + + #[error("admin room alias does not exist")] + MissingAdminRoomAlias, + + #[error("failed to check if {1} is a member of the room")] + CheckMembership(#[source] crate::utils::error::Error, OwnedUserId), + + #[error("failed to read saved admin bot localpart")] + ReadSavedAdminBotLocalpart(#[source] crate::utils::error::Error), + + #[error("failed to create {1}")] + CreateUser(#[source] crate::utils::error::Error, OwnedUserId), + + #[error("{1} failed to invite {2} to the room")] + PreviousInviteNew( + #[source] crate::utils::error::Error, + OwnedUserId, + OwnedUserId, + ), + + #[error("{1} failed to join the room")] + NewJoin(#[source] crate::utils::error::Error, OwnedUserId), + + #[error("failed to get the room state")] + GetState(#[source] crate::utils::error::Error), + + #[error("failed parse power level state")] + ParsePowerLevels(#[source] serde_json::Error), + + #[error("{1} failed to set the room state")] + SetState(#[source] crate::utils::error::Error, OwnedUserId), + + #[error("{1} failed to leave the room")] + PreviousLeave(#[source] crate::utils::error::Error, OwnedUserId), + + #[error("failed to save the new admin bot localpart")] + SaveAdminBotLocalpart(#[source] crate::utils::error::Error), +} diff --git a/src/service/admin.rs b/src/service/admin.rs index 1aeec5cc..7e69bbc7 100644 --- a/src/service/admin.rs +++ b/src/service/admin.rs @@ -20,7 +20,7 @@ use ruma::{ power_levels::RoomPowerLevelsEventContent, topic::RoomTopicEventContent, }, - TimelineEventType, + StateEventType, TimelineEventType, }, signatures::verify_json, EventId, MilliSecondsSinceUnixEpoch, OwnedMxcUri, OwnedRoomId, @@ -1528,6 +1528,200 @@ impl Service { Ok(()) } + #[allow(clippy::too_many_lines)] + pub(crate) async fn apply_admin_bot_localpart( + &self, + ) -> Result<(), crate::error::ApplyAdminBotLocalpart> { + use crate::error::ApplyAdminBotLocalpart as Error; + + let cur_user_id = &services().globals.admin_bot_user_id; + + let admin_room_id = self + .get_admin_room() + .map_err(Error::ResolveAdminRoomId)? + .ok_or(Error::MissingAdminRoomAlias)?; + + let cur_in_room = services() + .rooms + .state_cache + .is_joined(cur_user_id, &admin_room_id) + .map_err(|e| Error::CheckMembership(e, cur_user_id.clone()))?; + + let previous_user_id = { + let localpart = services() + .globals + .saved_admin_bot_localpart() + .map_err(Error::ReadSavedAdminBotLocalpart)? + .expect("admin bot localpart should have been saved by now"); + + let server_name = services().globals.server_name(); + + UserId::parse(format!("@{localpart}:{server_name}")).expect( + "localpart and server_name should have been validated by now", + ) + }; + + if cur_in_room { + // Don't need to make any changes + return Ok(()); + } + + // Rename variable because now we know it's a new value + let new_user_id = cur_user_id; + + // Create new + services() + .users + .create(new_user_id, None) + .map_err(|e| Error::CreateUser(e, new_user_id.clone()))?; + + // Acquire lock + let room_token = services() + .globals + .roomid_mutex_state + .lock_key(admin_room_id.clone()) + .await; + + // Make previous invite new + services() + .rooms + .timeline + .build_and_append_pdu( + PduBuilder { + event_type: TimelineEventType::RoomMember, + content: to_raw_value(&RoomMemberEventContent { + membership: MembershipState::Invite, + displayname: None, + avatar_url: None, + is_direct: None, + third_party_invite: None, + blurhash: None, + reason: None, + join_authorized_via_users_server: None, + }) + .expect("event is valid, we just created it"), + unsigned: None, + state_key: Some(new_user_id.to_string()), + redacts: None, + }, + &previous_user_id, + &room_token, + ) + .await + .map_err(|e| { + Error::PreviousInviteNew( + e, + previous_user_id.clone(), + new_user_id.clone(), + ) + })?; + + // Make new join + services() + .rooms + .timeline + .build_and_append_pdu( + PduBuilder { + event_type: TimelineEventType::RoomMember, + content: to_raw_value(&RoomMemberEventContent { + membership: MembershipState::Join, + displayname: None, + avatar_url: None, + is_direct: None, + third_party_invite: None, + blurhash: None, + reason: None, + join_authorized_via_users_server: None, + }) + .expect("event is valid, we just created it"), + unsigned: None, + state_key: Some(new_user_id.to_string()), + redacts: None, + }, + new_user_id, + &room_token, + ) + .await + .map_err(|e| Error::NewJoin(e, new_user_id.clone()))?; + + // Get current power_levels + let mut power_levels: RoomPowerLevelsEventContent = services() + .rooms + .state_accessor + .room_state_get( + &admin_room_id, + &StateEventType::RoomPowerLevels, + "", + ) + .map_err(Error::GetState)? + .map(|ev| serde_json::from_str(ev.content.get())) + .transpose() + .map_err(Error::ParsePowerLevels)? + .unwrap_or_default(); + + // Make previous promote new and demote self + services() + .rooms + .timeline + .build_and_append_pdu( + PduBuilder { + event_type: TimelineEventType::RoomPowerLevels, + content: to_raw_value({ + power_levels.users.remove(&previous_user_id); + power_levels + .users + .insert(new_user_id.clone(), 100.into()); + + &power_levels + }) + .expect("event is valid, we just created it"), + unsigned: None, + state_key: Some(String::new()), + redacts: None, + }, + &previous_user_id, + &room_token, + ) + .await + .map_err(|e| Error::SetState(e, previous_user_id.clone()))?; + + // Make previous leave + services() + .rooms + .timeline + .build_and_append_pdu( + PduBuilder { + event_type: TimelineEventType::RoomMember, + content: to_raw_value(&RoomMemberEventContent { + membership: MembershipState::Leave, + displayname: None, + avatar_url: None, + is_direct: None, + third_party_invite: None, + blurhash: None, + reason: None, + join_authorized_via_users_server: None, + }) + .expect("event is valid, we just created it"), + unsigned: None, + state_key: Some(previous_user_id.to_string()), + redacts: None, + }, + &previous_user_id, + &room_token, + ) + .await + .map_err(|e| Error::PreviousLeave(e, previous_user_id.clone()))?; + + // Save the new localpart + services() + .globals + .save_admin_bot_localpart(new_user_id.localpart()) + .map_err(Error::SaveAdminBotLocalpart)?; + + Ok(()) + } + /// Gets the room ID of the admin room /// /// Errors are propagated from the database, and will have None if there is diff --git a/src/service/globals.rs b/src/service/globals.rs index c3565338..ef19875e 100644 --- a/src/service/globals.rs +++ b/src/service/globals.rs @@ -234,14 +234,9 @@ impl Service { let admin_bot_user_id = UserId::parse(format!( "@{}:{}", - if config.conduit_compat { - "conduit" - } else { - "grapevine" - }, - config.server_name, + config.admin_bot_localpart, config.server_name, )) - .expect("admin bot user ID should be valid"); + .map_err(|_| Error::BadConfig("Invalid admin bot localpart"))?; let admin_bot_room_alias_id = RoomAliasId::parse(format!("#admins:{}", config.server_name))