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 ret.push(token);
54 cmd.clone_from(sub);
55 continue 'token;
56 } else if name.starts_with(&token) {
57 choice.push(name);
59 }
60 }
61
62 if choice.len() == 1 {
63 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 ret.push(token);
70 } else {
71 ret.push(common_prefix(&choice).into());
73 }
74
75 return ret.join(" ");
77 }
78
79 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 if !argv.is_empty() && argv[0].ends_with("admin") {
242 argv[0] = argv[0].trim_start_matches('\\').into();
243 }
244
245 if !argv.is_empty() && !argv[0].ends_with("admin") && !argv[0].starts_with('@') {
247 argv.insert(0, "admin".to_owned());
248 }
249
250 if argv.len() > 1 && argv[1] == "help" {
253 argv.remove(1);
254 argv.push("--help".to_owned());
255 }
256
257 if argv.len() > 1 && argv[1].contains('_') {
259 argv[1] = argv[1].replace('_', "-");
260 }
261
262 if argv.len() > 2 && argv[2].contains('_') {
264 argv[2] = argv[2].replace('_', "-");
265 }
266
267 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}