Skip to main content

tuwunel_service/admin/
mod.rs

1mod attach;
2pub mod console;
3pub mod context;
4pub mod create;
5mod execute;
6mod grant;
7mod notices;
8mod processor;
9mod register;
10mod respond;
11
12use std::{
13	collections::BTreeMap,
14	sync::{Arc, Mutex as StdMutex, RwLock as StdRwLock},
15	time::Instant,
16};
17
18use async_trait::async_trait;
19pub use context::Context;
20pub use create::create_admin_room;
21use futures::TryFutureExt;
22use ruma::{OwnedEventId, OwnedRoomAliasId, OwnedRoomId, RoomId, RoomOrAliasId, UserId};
23use tokio::sync::mpsc;
24use tuwunel_core::{Err, Event, Result, debug, err, error::default_log, warn};
25
26pub struct Service {
27	services: Arc<crate::services::OnceServices>,
28	channel: StdRwLock<Option<mpsc::Sender<CommandInput>>>,
29	pub command: StdRwLock<Option<Arc<dyn Command>>>,
30	pub admin_alias: OwnedRoomAliasId,
31	register_nonces: StdMutex<BTreeMap<String, Instant>>,
32	#[cfg(feature = "console")]
33	pub console: Arc<console::Console>,
34}
35
36/// Inputs to a command are a multi-line string and optional reply_id.
37#[derive(Clone, Debug, Default)]
38pub struct CommandInput {
39	pub command: String,
40	pub reply_id: Option<OwnedEventId>,
41}
42
43/// Root of a clap command tree installed by a downstream crate.
44#[async_trait]
45pub trait Command: Send + Sync + 'static {
46	/// The clap command tree; equivalent to
47	/// `<C as clap::CommandFactory>::command()`.
48	fn clap(&self) -> clap::Command;
49
50	/// Dispatch already-parsed argument matches to the matching handler.
51	async fn dispatch(&self, matches: clap::ArgMatches, context: &Context<'_>) -> Result;
52}
53
54/// Carries a rendered command outcome while preserving its status.
55///
56/// `Ok(Some(output))` reports success, `Err(output)` reports failure, and
57/// `Ok(None)` suppresses the response. Callers do not infer status from text.
58pub type ProcessorResult = Result<Option<CommandOutput>, CommandOutput>;
59
60/// Textual output of a completed command. Markdown is the norm; Plain carries
61/// clap usage and error text, which must never be markdown-rendered.
62pub enum CommandOutput {
63	Markdown(String),
64	Plain(String),
65}
66
67impl CommandOutput {
68	#[inline]
69	#[must_use]
70	pub fn as_str(&self) -> &str {
71		match self {
72			| Self::Markdown(text) | Self::Plain(text) => text,
73		}
74	}
75}
76
77/// Maximum number of commands which can be queued for dispatch.
78const COMMAND_QUEUE_LIMIT: usize = 512;
79
80#[async_trait]
81impl crate::Service for Service {
82	fn build(args: &crate::Args<'_>) -> Result<Arc<Self>> {
83		Ok(Arc::new(Self {
84			services: args.services.clone(),
85			channel: StdRwLock::new(None),
86			command: StdRwLock::new(None),
87			admin_alias: OwnedRoomAliasId::try_from(format!("#admins:{}", args.server.name))
88				.expect("#admins:server_name is valid alias name"),
89			register_nonces: StdMutex::new(BTreeMap::new()),
90			#[cfg(feature = "console")]
91			console: console::Console::new(args),
92		}))
93	}
94
95	async fn worker(self: Arc<Self>) -> Result {
96		let mut signals = self.services.server.signal.subscribe();
97		let (sender, mut receiver) = mpsc::channel(COMMAND_QUEUE_LIMIT);
98		_ = self
99			.channel
100			.write()
101			.expect("locked for writing")
102			.insert(sender);
103
104		self.console_auto_start().await;
105
106		loop {
107			tokio::select! {
108				command = receiver.recv() => match command {
109					Some(command) => self.handle_command(command).await,
110					None => break,
111				},
112				sig = signals.recv() => if let Ok(sig) = sig {
113					self.handle_signal(sig).await;
114				},
115			}
116		}
117
118		//TODO: not unwind safe
119		self.interrupt().await;
120		self.console_auto_stop().await;
121
122		Ok(())
123	}
124
125	async fn interrupt(&self) {
126		#[cfg(feature = "console")]
127		self.console.interrupt();
128
129		_ = self
130			.channel
131			.write()
132			.expect("locked for writing")
133			.take();
134	}
135
136	fn name(&self) -> &str { crate::service::make_name(std::module_path!()) }
137}
138
139impl Service {
140	/// Posts a command to the command processor queue and returns. Processing
141	/// will take place on the service worker's task asynchronously. Errors if
142	/// the queue is full.
143	pub async fn command(&self, command: String, reply_id: Option<OwnedEventId>) -> Result {
144		let Some(sender) = self
145			.channel
146			.read()
147			.expect("locked for reading")
148			.clone()
149		else {
150			return Err!("Admin command queue unavailable.");
151		};
152
153		sender
154			.send(CommandInput { command, reply_id })
155			.await
156			.map_err(|e| err!("Failed to enqueue admin command: {e:?}"))
157	}
158
159	/// Dispatches a command to the processor on the current task and waits for
160	/// completion.
161	pub async fn command_in_place(
162		&self,
163		command: String,
164		reply_id: Option<OwnedEventId>,
165	) -> ProcessorResult {
166		self.process_command(&CommandInput { command, reply_id })
167			.await
168	}
169
170	/// Invokes the tab-completer to complete the command. When unavailable,
171	/// None is returned.
172	pub fn complete_command(&self, command: &str) -> Option<String> {
173		self.command
174			.read()
175			.expect("locked for reading")
176			.as_ref()
177			.map(|root| processor::complete(root.clap(), command))
178	}
179
180	async fn handle_signal(&self, sig: &'static str) {
181		if sig == execute::SIGNAL {
182			self.signal_execute().await.ok();
183		}
184
185		#[cfg(feature = "console")]
186		self.console.handle_signal(sig);
187	}
188
189	async fn handle_command(&self, command: CommandInput) {
190		match self.process_command(&command).await {
191			| Ok(None) => debug!("Command successful with no response"),
192			| Err(output) | Ok(Some(output)) => self
193				.handle_response(output, command.reply_id.as_deref())
194				.await
195				.unwrap_or_else(default_log),
196		}
197	}
198
199	async fn process_command(&self, command: &CommandInput) -> ProcessorResult {
200		let root = self
201			.command
202			.read()
203			.expect("locked for reading")
204			.clone()
205			.expect("Admin module is not loaded");
206
207		processor::handle_command(root, Arc::clone(self.services.get()), command).await
208	}
209
210	/// Checks whether a given user is an admin of this server
211	pub async fn user_is_admin(&self, user_id: &UserId) -> bool {
212		if user_id == self.services.globals.server_user {
213			return true;
214		}
215
216		let Ok(admin_room) = self.get_admin_room().await else {
217			return false;
218		};
219
220		self.services
221			.state_cache
222			.is_joined(user_id, &admin_room)
223			.await
224	}
225
226	/// Gets the room ID of the admin room
227	///
228	/// Errors are propagated from the database, and will have None if there is
229	/// no admin room
230	pub async fn get_admin_room(&self) -> Result<OwnedRoomId> {
231		let room_id = self
232			.services
233			.alias
234			.resolve_local_alias(&self.admin_alias)
235			.await?;
236
237		self.services
238			.state_cache
239			.is_joined(&self.services.globals.server_user, &room_id)
240			.await
241			.then_some(room_id)
242			.ok_or_else(|| err!(Request(NotFound("Admin user not joined to admin room"))))
243	}
244
245	/// Gets the room reports are posted to: the configured report room when set
246	/// and usable, otherwise the admin room.
247	pub async fn get_report_room(&self) -> Result<OwnedRoomId> {
248		let Some(report_room) = self.services.server.config.report_room.as_ref() else {
249			return self.get_admin_room().await;
250		};
251
252		match self.resolve_report_room(report_room).await {
253			| Ok(room_id) => Ok(room_id),
254			| Err(e) => {
255				warn!(%report_room, error = %e, "Falling back to the admin room for reports");
256				self.get_admin_room().await
257			},
258		}
259	}
260
261	async fn resolve_report_room(&self, report_room: &RoomOrAliasId) -> Result<OwnedRoomId> {
262		let room_id = self
263			.services
264			.alias
265			.maybe_resolve(report_room)
266			.await?;
267
268		self.services
269			.state_cache
270			.is_joined(&self.services.globals.server_user, &room_id)
271			.await
272			.then_some(room_id)
273			.ok_or_else(|| err!("server user is not joined to the configured report room"))
274	}
275
276	pub async fn is_admin_command<Pdu>(&self, event: &Pdu, body: &str) -> bool
277	where
278		Pdu: Event,
279	{
280		// Server-side command-escape with public echo
281		let is_escape = body.starts_with('\\');
282		let is_public_escape = is_escape
283			&& body
284				.trim_start_matches('\\')
285				.starts_with("!admin");
286
287		// Admin command with public echo (in admin room)
288		let server_user = &self.services.globals.server_user;
289		let is_public_prefix =
290			body.starts_with("!admin") || body.starts_with(server_user.as_str());
291
292		// Expected backward branch
293		if !is_public_escape && !is_public_prefix {
294			return false;
295		}
296
297		let user_is_local = self
298			.services
299			.globals
300			.user_is_local(event.sender());
301
302		// only allow public escaped commands by local admins
303		if is_public_escape && !user_is_local {
304			return false;
305		}
306
307		// Check if server-side command-escape is disabled by configuration
308		if is_public_escape && !self.services.server.config.admin_escape_commands {
309			return false;
310		}
311
312		// Prevent unescaped !admin from being used outside of the admin room
313		if is_public_prefix && !self.is_admin_room(event.room_id()).await {
314			return false;
315		}
316
317		// Only senders who are admin can proceed
318		if !self.user_is_admin(event.sender()).await {
319			return false;
320		}
321
322		// This will evaluate to false if the emergency password is set up so that
323		// the administrator can execute commands as the server user
324		let emergency_password_set = self
325			.services
326			.server
327			.config
328			.emergency_password
329			.is_some();
330		let from_server = event.sender() == server_user && !emergency_password_set;
331		if from_server && self.is_admin_room(event.room_id()).await {
332			return false;
333		}
334
335		// Authentic admin command
336		true
337	}
338
339	#[must_use]
340	pub async fn is_admin_room(&self, room_id_: &RoomId) -> bool {
341		self.get_admin_room()
342			.map_ok(|room_id| room_id == room_id_)
343			.await
344			.unwrap_or(false)
345	}
346}