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 timer: SystemTime::now(),
95 output: String::new().into(),
96 };
97
98 let (result, mut logs) = process(&context, command, matches, &args).await;
99
100 let output = take(&mut *context.output.lock().await);
101
102 match result {
103 | Ok(()) if logs.is_empty() => Ok(Some(CommandOutput::Markdown(output))),
104
105 | Ok(()) => {
106 logs.write_str(output.as_str())
107 .expect("output buffer");
108
109 Ok(Some(CommandOutput::Markdown(logs)))
110 },
111 | Err(error) => {
112 write!(&mut logs, "Command failed with error:\n```\n{error:#?}\n```")
113 .expect("output buffer");
114
115 Err(CommandOutput::Markdown(logs))
116 },
117 }
118}
119
120fn handle_panic(error: &Error) -> ProcessorResult {
121 let link =
122 "Please submit a [bug report](https://github.com/matrix-construct/tuwunel/issues/new). \
123 🥺";
124
125 let msg = format!("Panic occurred while processing command:\n```\n{error:#?}\n```\n{link}");
126
127 error!("Panic while processing command: {error:?}");
128 Err(CommandOutput::Markdown(msg))
129}
130
131async fn process(
132 context: &Context<'_>,
133 command: &dyn Command,
134 matches: clap::ArgMatches,
135 args: &[String],
136) -> (Result, String) {
137 let (capture, logs) = capture_create(context);
138
139 let capture_scope = capture.start();
140 let result = Box::pin(command.dispatch(matches, context)).await;
141 drop(capture_scope);
142
143 debug!(
144 ok = result.is_ok(),
145 elapsed = ?context.timer.elapsed(),
146 command = ?args,
147 "command processed"
148 );
149
150 let mut output = String::new();
151
152 let logs = logs.lock().expect("locked");
153 if logs.lines().count() > 2 {
154 writeln!(&mut output, "{logs}").expect("failed to format logs to command output");
155 }
156 drop(logs);
157
158 (result, output)
159}
160
161fn capture_create(context: &Context<'_>) -> (Arc<Capture>, Arc<Mutex<String>>) {
162 let env_config = &context.services.server.config.admin_log_capture;
163 let env_filter = EnvFilter::try_new(env_config).unwrap_or_else(|e| {
164 warn!("admin_log_capture filter invalid: {e:?}");
165 cfg!(debug_assertions)
166 .then_some("debug")
167 .or(Some("info"))
168 .map(Into::into)
169 .expect("default capture EnvFilter")
170 });
171
172 let log_level = env_filter
173 .max_level_hint()
174 .and_then(LevelFilter::into_level)
175 .unwrap_or(Level::DEBUG);
176
177 let filter = move |data: capture::Data<'_>| {
178 data.level() <= log_level && data.our_modules() && data.scope.contains(&"admin")
179 };
180
181 let logs = Arc::new(Mutex::new(
182 collect_stream(|s| markdown_table_head(s)).expect("markdown table header"),
183 ));
184
185 let capture = Capture::new(
186 &context.services.server.log.capture,
187 Some(filter),
188 capture::fmt(markdown_table, logs.clone()),
189 );
190
191 (capture, logs)
192}
193
194fn parse<'a>(
195 services: &Arc<Services>,
196 cmd: clap::Command,
197 input: &'a CommandInput,
198) -> Result<(clap::ArgMatches, Vec<String>, Vec<&'a str>), CommandOutput> {
199 let lines = input
200 .command
201 .lines()
202 .filter(|line| !line.trim().is_empty());
203
204 let command_line = lines
205 .clone()
206 .next()
207 .expect("command missing first line");
208
209 let body = lines.skip(1).collect();
210
211 match parse_command(cmd, command_line) {
212 | Ok((matches, args)) => Ok((matches, args, body)),
213 | Err(error) => {
214 let message = error
215 .to_string()
216 .replace("server.name", services.globals.server_name().as_str());
217
218 Err(CommandOutput::Plain(message))
219 },
220 }
221}
222
223fn parse_command(
224 mut cmd: clap::Command,
225 line: &str,
226) -> Result<(clap::ArgMatches, Vec<String>), clap::Error> {
227 let argv = parse_line(line);
228 let matches = cmd.try_get_matches_from_mut(&argv)?;
229
230 Ok((matches, argv))
231}
232
233fn parse_line(command_line: &str) -> Vec<String> {
234 let mut argv = command_line
235 .split_whitespace()
236 .map(str::to_owned)
237 .collect::<Vec<String>>();
238
239 if !argv.is_empty() && argv[0].ends_with("admin") {
241 argv[0] = argv[0].trim_start_matches('\\').into();
242 }
243
244 if !argv.is_empty() && !argv[0].ends_with("admin") && !argv[0].starts_with('@') {
246 argv.insert(0, "admin".to_owned());
247 }
248
249 if argv.len() > 1 && argv[1] == "help" {
252 argv.remove(1);
253 argv.push("--help".to_owned());
254 }
255
256 if argv.len() > 1 && argv[1].contains('_') {
258 argv[1] = argv[1].replace('_', "-");
259 }
260
261 if argv.len() > 2 && argv[2].contains('_') {
263 argv[2] = argv[2].replace('_', "-");
264 }
265
266 if argv.len() > 3 && argv[1].eq("query") {
269 argv[3] = argv[3].replace('_', "-");
270 }
271
272 trace!(?command_line, ?argv, "parse");
273 argv
274}