quorum_private/cli/mod.rs
1//! Main entry point for the server cli Commands
2//! This is where commands are defined properly, and where command functions are called.
3
4pub mod config;
5pub mod help;
6pub mod server;
7
8use crate::cli::config::{change_config_value, print_all};
9use crate::cli::server::confirm_and_delete;
10use colored::Colorize;
11use quorum_core::cli::AdminSession;
12use quorum_core::cli::db;
13use quorum_core::cli::server::{audit, logout, logs, shutdown, status};
14use quorum_core::db::DB;
15use std::io::{self, Write};
16use std::sync::{Arc, Mutex};
17use std::time::{Duration, Instant};
18use tokio::sync::watch;
19
20const SESSION_TIMEOUT_MINS: u64 = 20;
21
22struct Command {
23 parts: Vec<String>,
24 #[allow(dead_code)]
25 params: Vec<String>,
26 raw: String,
27}
28
29macro_rules! config_match_arms {
30 ($session:expr, $cmd:expr, $command:expr, $($field:ident),*) => {
31 match $command.as_str() {
32 "show" => {
33 if !require_admin($session) {
34 return;
35 }
36 print_all();
37 }
38 $(
39 stringify!($field) => {
40 if !require_admin($session) {
41 return;
42 }
43 let value = $cmd.raw.split_once(' ').map(|x| x.1).unwrap_or("");
44 change_config_value(stringify!($field), value);
45 }
46 )*
47 _ => unknown(&$cmd.raw),
48 }
49 };
50}
51
52/// Parses a command string into a Command struct.
53///
54/// Commands use colon between the command category and the actual command,
55/// making parsing very simple and easy to do.
56///
57/// # Arguments
58/// * `input` - A string slice that holds the command input.
59///
60/// # Returns
61/// * `Option<Command>` - Returns Some(Command) if parsing is successful, or None if the input is empty.
62fn parse(input: &str) -> Option<Command> {
63 let trimmed = input.trim();
64 if trimmed.is_empty() {
65 return None;
66 }
67
68 let (cmd_part, params_part) = match trimmed.split_once(' ') {
69 Some((cmd, params)) => (cmd, params.trim()),
70 None => (trimmed, ""),
71 };
72
73 let parts: Vec<String> = cmd_part
74 .split(':')
75 .map(|s| s.trim().to_lowercase())
76 .filter(|s| !s.is_empty())
77 .collect();
78
79 let params: Vec<String> = if params_part.is_empty() {
80 vec![]
81 } else {
82 params_part
83 .split(", ")
84 .map(|s| s.trim().to_string())
85 .filter(|s| !s.is_empty())
86 .collect()
87 };
88
89 Some(Command {
90 parts,
91 params,
92 raw: trimmed.to_string(),
93 })
94}
95
96/// Spawns the CLI in a separate blocking thread.
97///
98/// Runs an interactive command loop that listens for user input and dispatches commands
99/// to their respective handlers. The CLI operates on a dedicated thread to avoid blocking
100/// the async Tokio runtime, allowing the server to continue handling HTTP requests
101/// concurrently.
102///
103/// Session expiry is enforced—after 20 minutes of inactivity, users must re-authenticate
104/// with `server:login` before executing protected commands.
105///
106/// The CLI automatically exits when a shutdown signal is received via `shutdown_tx`.
107///
108/// # Arguments
109/// * `db` - A reference to the database connection, passed to all command handlers.
110/// * `server_start` - The instant when the server started, used for uptime calculations in `server:status`.
111/// * `shutdown_tx` - A watch channel sender that signals the CLI to exit when the server is shutting down.
112///
113/// # Example
114/// ```rust
115/// let (shutdown_tx, shutdown_rx) = tokio::sync::watch::channel(false);
116/// cli::spawn_cli(db, server_start, shutdown_tx).await;
117/// ```
118pub async fn spawn_cli(db: DB, server_start: Instant, shutdown_tx: watch::Sender<bool>) {
119 let session = Arc::new(Mutex::new(AdminSession::new()));
120 let handle = tokio::runtime::Handle::current();
121 let shutdown_rx = shutdown_tx.subscribe();
122
123 tokio::task::spawn_blocking(move || {
124 std::thread::sleep(Duration::from_millis(600));
125
126 loop {
127 // Check if shutdown was signaled
128 if shutdown_rx.has_changed().unwrap_or(false) {
129 break;
130 }
131
132 print!("{} ", ">".cyan().bold());
133 io::stdout().flush().unwrap();
134
135 let mut input = String::new();
136 match io::stdin().read_line(&mut input) {
137 Ok(0) => break, // EOF
138 Ok(_) => {}
139 Err(_) => break,
140 }
141
142 {
143 let mut sess = session.lock().unwrap();
144 if sess.logged_in && !sess.is_valid() {
145 println!(
146 "{}",
147 "Session expired after 20 minutes of inactivity. Please run server:login again."
148 .yellow()
149 );
150 sess.logout();
151 }
152 }
153
154 let cmd = match parse(&input) {
155 Some(c) => c,
156 None => continue,
157 };
158
159 handle.block_on(dispatch(&cmd, &db, server_start, &session, &shutdown_tx));
160 }
161 });
162}
163
164/// Dispatches the parsed command to the appropriate handler function.
165///
166/// # Arguments
167/// * `cmd` - A reference to the parsed Command struct.
168/// * `db` - A reference to the database connection.
169/// * `server_start` - The instant when the server started, used for uptime calculations.
170/// * `session` - A reference to the admin session, wrapped in Arc<Mutex<>> for thread-safe access.
171/// * `shutdown_tx` - A watch channel sender to signal server shutdown.
172async fn dispatch(
173 cmd: &Command,
174 db: &DB,
175 server_start: Instant,
176 session: &Arc<Mutex<AdminSession>>,
177 shutdown_tx: &watch::Sender<bool>,
178) {
179 match cmd.parts.as_slice() {
180 // -- help --
181 [ns] if ns == "help" => {
182 help::print_all();
183 }
184 [ns, command] if ns == "help" => {
185 help::print_command(command);
186 }
187
188 // -- Server --
189 [ns, command] if ns == "server" => match command.as_str() {
190 "login" => server::login(session),
191 "status" => status(server_start).await,
192 "logout" => logout(session),
193 "shutdown" => {
194 if !require_admin(session) {
195 return;
196 }
197 shutdown(db, server_start, shutdown_tx).await;
198 }
199 "logs" => {
200 let params = cmd.raw.split_once(' ').map(|x| x.1).unwrap_or("");
201 logs(db, params).await;
202 }
203 "audit" => {
204 let params = cmd.raw.split_once(' ').map(|x| x.1).unwrap_or("");
205 audit(db, params).await;
206 }
207 _ => unknown(&cmd.raw),
208 },
209
210 // -- Database --
211 [ns, command] if ns == "db" => match command.as_str() {
212 "stats" => db::stats(db).await,
213 "table" => {
214 let params = cmd.raw.split_once(' ').map(|x| x.1).unwrap_or("");
215 db::table(db, params).await;
216 }
217 "delete" => {
218 if !require_admin(session) {
219 return;
220 }
221 confirm_and_delete(shutdown_tx).await;
222 }
223 _ => unknown(&cmd.raw),
224 },
225
226 // -- config --
227 [ns, command] if ns == "config" => {
228 config_match_arms!(
229 session,
230 cmd,
231 command,
232 server_port,
233 server_url,
234 server_host,
235 surreal_data_path,
236 surreal_ns,
237 surreal_db,
238 jwt_secret,
239 jwt_access_minutes,
240 jwt_refresh_days,
241 enable_testing,
242 default_per_second,
243 default_burst_size,
244 testing_per_second,
245 testing_burst_size
246 );
247 }
248
249 _ => unknown(&cmd.raw),
250 }
251}
252
253/// Checks if a command required admin privileges.
254///
255/// Uses the `AdminSession` built struct to determine if the current session is valid and has admin privileges.
256/// If not, it prints an error message and returns false, stopping the command from executing.
257///
258/// The intention of this functionality is so that if someone was to have gotten unauthorised access to the actively running server
259/// and try to run a command, this safeguards against them running anything dangerous or destructive without first authenticating as an admin user.
260/// Which without the correct account information of the admin user, they are unable to do any serious harm.
261///
262/// # Arguments
263/// * `session` - A reference to the admin session, wrapped in Arc<Mutex<>> for thread-safe access.
264///
265/// # Returns
266/// * `bool` - Returns true if the session is valid and has admin privileges, false otherwise.
267///
268/// # Example
269/// ```rust
270/// if !require_admin(session) {
271/// return;
272/// }
273///```
274pub fn require_admin(session: &Arc<Mutex<AdminSession>>) -> bool {
275 let sess = session.lock().unwrap();
276 if !sess.is_valid() {
277 println!(
278 "{}",
279 "This command requires authentication. Please run server:login first.".red()
280 );
281 return false;
282 }
283 if !sess.is_admin {
284 println!("{}", "This command requires admin privileges.".red());
285 return false;
286 }
287 true
288}
289
290/// Prints an error message for unknown commands.
291///
292/// # Arguments
293/// * `raw` - The raw command string that was not recognized.
294fn unknown(raw: &str) {
295 println!(
296 "{} {} {}",
297 "Unknown command:".red(),
298 raw.white(),
299 "- type `help` to see available commands.".dimmed()
300 );
301}