Skip to main content

tuwunel_service/admin/
processor.rs

1use std::{
2	fmt::Write,
3	mem::take,
4	panic::AssertUnwindSafe,
5	sync::{Arc, Mutex},
6	time::SystemTime,
7};
8
9use futures::future::FutureExt;
10use tracing::Level;
11use tracing_subscriber::{EnvFilter, filter::LevelFilter};
12use tuwunel_core::{
13	Error, Result, debug, error,
14	log::{
15		capture,
16		capture::Capture,
17		fmt::{markdown_table, markdown_table_head},
18	},
19	trace,
20	utils::string::{collect_stream, common_prefix},
21	warn,
22};
23
24use super::{Command, CommandInput, CommandOutput, Context, ProcessorResult};
25use crate::Services;
26
27#[tracing::instrument(level = "debug", skip_all, name = "admin")]
28pub(super) async fn handle_command(
29	command: Arc<dyn Command>,
30	services: Arc<Services>,
31	input: &CommandInput,
32) -> ProcessorResult {
33	AssertUnwindSafe(Box::pin(process_command(&*command, services, input)))
34		.catch_unwind()
35		.await
36		.map_err(Error::from_panic)
37		.unwrap_or_else(|error| handle_panic(&error))
38}
39
40#[must_use]
41pub(super) fn complete(mut cmd: clap::Command, line: &str) -> String {
42	let argv = parse_line(line);
43	let mut ret = Vec::<String>::with_capacity(argv.len().saturating_add(1));
44
45	'token: for token in argv.into_iter().skip(1) {
46		let cmd_ = cmd.clone();
47		let mut choice = Vec::new();
48
49		for sub in cmd_.get_subcommands() {
50			let name = sub.get_name();
51			if *name == token {
52				// token already complete; recurse to subcommand
53				ret.push(token);
54				cmd.clone_from(sub);
55				continue 'token;
56			} else if name.starts_with(&token) {
57				// partial match; add to choices
58				choice.push(name);
59			}
60		}
61
62		if choice.len() == 1 {
63			// One choice. Add extra space because it's complete
64			let choice = *choice.first().expect("only choice");
65			ret.push(choice.to_owned());
66			ret.push(String::new());
67		} else if choice.is_empty() {
68			// Nothing found, return original string
69			ret.push(token);
70		} else {
71			// Find the common prefix
72			ret.push(common_prefix(&choice).into());
73		}
74
75		// Return from completion
76		return ret.join(" ");
77	}
78
79	// Return from no completion. Needs a space though.
80	ret.push(String::new());
81	ret.join(" ")
82}
83
84async fn process_command(
85	command: &dyn Command,
86	services: Arc<Services>,
87	input: &CommandInput,
88) -> ProcessorResult {
89	let (matches, args, body) = parse(&services, command.clap(), input)?;
90
91	let context = Context {
92		services: &services,
93		body: &body,
94		sender: input.sender.as_deref(),
95		timer: SystemTime::now(),
96		output: String::new().into(),
97	};
98
99	let (result, mut logs) = process(&context, command, matches, &args).await;
100
101	let output = take(&mut *context.output.lock().await);
102
103	match result {
104		| Ok(()) if logs.is_empty() => Ok(Some(CommandOutput::Markdown(output))),
105
106		| Ok(()) => {
107			logs.write_str(output.as_str())
108				.expect("output buffer");
109
110			Ok(Some(CommandOutput::Markdown(logs)))
111		},
112		| Err(error) => {
113			write!(&mut logs, "Command failed with error:\n```\n{error:#?}\n```")
114				.expect("output buffer");
115
116			Err(CommandOutput::Markdown(logs))
117		},
118	}
119}
120
121fn handle_panic(error: &Error) -> ProcessorResult {
122	let link =
123		"Please submit a [bug report](https://github.com/matrix-construct/tuwunel/issues/new). \
124		 🥺";
125
126	let msg = format!("Panic occurred while processing command:\n```\n{error:#?}\n```\n{link}");
127
128	error!("Panic while processing command: {error:?}");
129	Err(CommandOutput::Markdown(msg))
130}
131
132async fn process(
133	context: &Context<'_>,
134	command: &dyn Command,
135	matches: clap::ArgMatches,
136	args: &[String],
137) -> (Result, String) {
138	let (capture, logs) = capture_create(context);
139
140	let capture_scope = capture.start();
141	let result = Box::pin(command.dispatch(matches, context)).await;
142	drop(capture_scope);
143
144	debug!(
145		ok = result.is_ok(),
146		elapsed = ?context.timer.elapsed(),
147		command = ?args,
148		"command processed"
149	);
150
151	let mut output = String::new();
152
153	let logs = logs.lock().expect("locked");
154	if logs.lines().count() > 2 {
155		writeln!(&mut output, "{logs}").expect("failed to format logs to command output");
156	}
157	drop(logs);
158
159	(result, output)
160}
161
162fn capture_create(context: &Context<'_>) -> (Arc<Capture>, Arc<Mutex<String>>) {
163	let env_config = &context.services.server.config.admin_log_capture;
164	let env_filter = EnvFilter::try_new(env_config).unwrap_or_else(|e| {
165		warn!("admin_log_capture filter invalid: {e:?}");
166		cfg!(debug_assertions)
167			.then_some("debug")
168			.or(Some("info"))
169			.map(Into::into)
170			.expect("default capture EnvFilter")
171	});
172
173	let log_level = env_filter
174		.max_level_hint()
175		.and_then(LevelFilter::into_level)
176		.unwrap_or(Level::DEBUG);
177
178	let filter = move |data: capture::Data<'_>| {
179		data.level() <= log_level && data.our_modules() && data.scope.contains(&"admin")
180	};
181
182	let logs = Arc::new(Mutex::new(
183		collect_stream(|s| markdown_table_head(s)).expect("markdown table header"),
184	));
185
186	let capture = Capture::new(
187		&context.services.server.log.capture,
188		Some(filter),
189		capture::fmt(markdown_table, logs.clone()),
190	);
191
192	(capture, logs)
193}
194
195fn parse<'a>(
196	services: &Arc<Services>,
197	cmd: clap::Command,
198	input: &'a CommandInput,
199) -> Result<(clap::ArgMatches, Vec<String>, Vec<&'a str>), CommandOutput> {
200	let lines = input
201		.command
202		.lines()
203		.filter(|line| !line.trim().is_empty());
204
205	let command_line = lines
206		.clone()
207		.next()
208		.expect("command missing first line");
209
210	let body = lines.skip(1).collect();
211
212	match parse_command(cmd, command_line) {
213		| Ok((matches, args)) => Ok((matches, args, body)),
214		| Err(error) => {
215			let message = error
216				.to_string()
217				.replace("server.name", services.globals.server_name().as_str());
218
219			Err(CommandOutput::Plain(message))
220		},
221	}
222}
223
224fn parse_command(
225	mut cmd: clap::Command,
226	line: &str,
227) -> Result<(clap::ArgMatches, Vec<String>), clap::Error> {
228	let argv = parse_line(line);
229	let matches = cmd.try_get_matches_from_mut(&argv)?;
230
231	Ok((matches, argv))
232}
233
234fn parse_line(command_line: &str) -> Vec<String> {
235	let mut argv = command_line
236		.split_whitespace()
237		.map(str::to_owned)
238		.collect::<Vec<String>>();
239
240	// Remove any escapes that came with a server-side escape command
241	if !argv.is_empty() && argv[0].ends_with("admin") {
242		argv[0] = argv[0].trim_start_matches('\\').into();
243	}
244
245	// First indice has to be "admin" but for console convenience we add it here
246	if !argv.is_empty() && !argv[0].ends_with("admin") && !argv[0].starts_with('@') {
247		argv.insert(0, "admin".to_owned());
248	}
249
250	// Replace `help command` with `command --help`
251	// Clap has a help subcommand, but it omits the long help description.
252	if argv.len() > 1 && argv[1] == "help" {
253		argv.remove(1);
254		argv.push("--help".to_owned());
255	}
256
257	// Backwards compatibility with `register_appservice`-style commands
258	if argv.len() > 1 && argv[1].contains('_') {
259		argv[1] = argv[1].replace('_', "-");
260	}
261
262	// Backwards compatibility with `register_appservice`-style commands
263	if argv.len() > 2 && argv[2].contains('_') {
264		argv[2] = argv[2].replace('_', "-");
265	}
266
267	// if the user is using the `query` command (argv[1]), replace the database
268	// function/table calls with underscores to match the codebase
269	if argv.len() > 3 && argv[1].eq("query") {
270		argv[3] = argv[3].replace('_', "-");
271	}
272
273	trace!(?command_line, ?argv, "parse");
274	argv
275}