microsandbox_server/
management.rs

1//! Management module for the microsandbox server.
2//!
3//! This module provides functionality for managing the microsandbox server lifecycle, including:
4//! - Starting and stopping the server
5//! - API key generation and management
6//! - Process management and signal handling
7//! - Server configuration and state management
8//!
9//! The module implements core server management features such as:
10//! - Secure server key generation and storage
11//! - PID file management for process tracking
12//! - Signal handling for graceful shutdown
13//! - JWT-based API key generation and formatting
14
15use std::{path::PathBuf, process::Stdio};
16
17use chrono::{Duration, Utc};
18use jsonwebtoken::{EncodingKey, Header};
19#[cfg(feature = "cli")]
20use microsandbox_utils::term;
21use microsandbox_utils::{
22    env, DEFAULT_MSBSERVER_EXE_PATH, MSBSERVER_EXE_ENV_VAR, NAMESPACES_SUBDIR, SERVER_KEY_FILE,
23    SERVER_PID_FILE,
24};
25use rand::{distr::Alphanumeric, Rng};
26use serde::{Deserialize, Serialize};
27use tokio::{fs, process::Command};
28
29use crate::{MicrosandboxServerError, MicrosandboxServerResult};
30
31//--------------------------------------------------------------------------------------------------
32// Constants
33//--------------------------------------------------------------------------------------------------
34
35/// Prefix for the API key
36pub const API_KEY_PREFIX: &str = "msb_";
37
38/// Length of the server key
39const SERVER_KEY_LENGTH: usize = 32;
40
41#[cfg(feature = "cli")]
42const START_SERVER_MSG: &str = "Start sandbox server";
43
44#[cfg(feature = "cli")]
45const STOP_SERVER_MSG: &str = "Stop sandbox server";
46
47#[cfg(feature = "cli")]
48const KEYGEN_MSG: &str = "Generate new API key";
49
50//--------------------------------------------------------------------------------------------------
51// Types
52//--------------------------------------------------------------------------------------------------
53
54/// Claims for the JWT token
55#[derive(Debug, Serialize, Deserialize)]
56pub struct Claims {
57    /// Expiration time
58    pub exp: u64,
59
60    /// Issued at time
61    pub iat: u64,
62
63    /// Namespace
64    pub namespace: String,
65}
66
67//--------------------------------------------------------------------------------------------------
68// Functions
69//--------------------------------------------------------------------------------------------------
70
71/// Start the sandbox server
72pub async fn start(
73    key: Option<String>,
74    port: Option<u16>,
75    namespace_dir: Option<PathBuf>,
76    dev_mode: bool,
77    detach: bool,
78    reset_key: bool,
79) -> MicrosandboxServerResult<()> {
80    // Ensure microsandbox home directory exists
81    let microsandbox_home_path = env::get_microsandbox_home_path();
82    fs::create_dir_all(&microsandbox_home_path).await?;
83
84    // Ensure namespace directory exists
85    let namespace_path = microsandbox_home_path.join(NAMESPACES_SUBDIR);
86    fs::create_dir_all(&namespace_path).await?;
87
88    #[cfg(feature = "cli")]
89    let start_server_sp = term::create_spinner(START_SERVER_MSG.to_string(), None, None);
90
91    // Check if PID file exists, indicating a server might be running
92    let pid_file_path = microsandbox_home_path.join(SERVER_PID_FILE);
93    if pid_file_path.exists() {
94        // Read PID from file
95        let pid_str = fs::read_to_string(&pid_file_path).await?;
96        if let Ok(pid) = pid_str.trim().parse::<i32>() {
97            // Check if process is actually running
98            let process_running = unsafe { libc::kill(pid, 0) == 0 };
99
100            if process_running {
101                #[cfg(feature = "cli")]
102                term::finish_with_error(&start_server_sp);
103
104                #[cfg(feature = "cli")]
105                println!(
106                    "A sandbox server is already running (PID: {}) - Use {} to stop it",
107                    pid,
108                    console::style("msb server stop").yellow()
109                );
110
111                tracing::info!(
112                    "A sandbox server is already running (PID: {}). Use 'msb server stop' to stop it",
113                    pid
114                );
115
116                return Ok(());
117            } else {
118                // Process not running, clean up stale PID file
119                tracing::warn!("found stale PID file for process {}. Cleaning up.", pid);
120                clean(&pid_file_path).await?;
121            }
122        } else {
123            // Invalid PID in file, clean up
124            tracing::warn!("found invalid PID in server.pid file. Cleaning up.");
125            clean(&pid_file_path).await?;
126        }
127    }
128
129    // Get the path to the msbrun executable
130    let msbserver_path = microsandbox_utils::path::resolve_env_path(
131        MSBSERVER_EXE_ENV_VAR,
132        &*DEFAULT_MSBSERVER_EXE_PATH,
133    )
134    .map_err(|e| {
135        #[cfg(feature = "cli")]
136        term::finish_with_error(&start_server_sp);
137        e
138    })?;
139
140    let mut command = Command::new(msbserver_path);
141
142    if dev_mode {
143        command.arg("--dev");
144    }
145
146    if let Some(port) = port {
147        command.arg("--port").arg(port.to_string());
148    }
149
150    if let Some(namespace_dir) = namespace_dir {
151        command.arg("--path").arg(namespace_dir);
152    }
153
154    // Handle secure non-dev mode
155    if !dev_mode {
156        // Create a key file with either the provided key or a generated one
157        let key_file_path = microsandbox_home_path.join(SERVER_KEY_FILE);
158
159        // Store if a key was provided before consuming the option
160        let key_provided = key.is_some();
161
162        let server_key = if let Some(key) = key {
163            // Use the provided key
164            command.arg("--key").arg(&key);
165            key
166        } else if key_file_path.exists() && !reset_key {
167            // Use existing key file if it exists and reset_key is not set
168            let existing_key = fs::read_to_string(&key_file_path).await.map_err(|e| {
169                #[cfg(feature = "cli")]
170                term::finish_with_error(&start_server_sp);
171
172                MicrosandboxServerError::StartError(format!(
173                    "failed to read existing key file {}: {}",
174                    key_file_path.display(),
175                    e
176                ))
177            })?;
178            command.arg("--key").arg(&existing_key);
179            existing_key
180        } else {
181            // Generate a new random key
182            let generated_key = generate_random_key();
183            command.arg("--key").arg(&generated_key);
184            generated_key
185        };
186
187        // Write the key to file (if it's a new key or we're resetting)
188        if !key_file_path.exists() || key_provided || reset_key {
189            fs::write(&key_file_path, &server_key).await.map_err(|e| {
190                #[cfg(feature = "cli")]
191                term::finish_with_error(&start_server_sp);
192
193                MicrosandboxServerError::StartError(format!(
194                    "failed to write key file {}: {}",
195                    key_file_path.display(),
196                    e
197                ))
198            })?;
199
200            tracing::info!("created server key file at {}", key_file_path.display());
201        }
202    }
203
204    if detach {
205        unsafe {
206            command.pre_exec(|| {
207                libc::setsid();
208                Ok(())
209            });
210        }
211
212        // TODO: Redirect to log file
213        // Redirect the i/o to /dev/null
214        command.stdout(Stdio::null());
215        command.stderr(Stdio::null());
216        command.stdin(Stdio::null());
217    }
218
219    // Only pass RUST_LOG if it's set in the environment
220    if let Ok(rust_log) = std::env::var("RUST_LOG") {
221        tracing::debug!("using existing RUST_LOG: {:?}", rust_log);
222        command.env("RUST_LOG", rust_log);
223    }
224
225    let mut child = command.spawn().map_err(|e| {
226        #[cfg(feature = "cli")]
227        term::finish_with_error(&start_server_sp);
228
229        MicrosandboxServerError::StartError(format!("failed to spawn server process: {}", e))
230    })?;
231
232    let pid = child.id().unwrap_or(0);
233    tracing::info!("started sandbox server process with PID: {}", pid);
234
235    // Create PID file
236    let pid_file_path = microsandbox_home_path.join(SERVER_PID_FILE);
237
238    // Ensure microsandbox home directory exists
239    fs::create_dir_all(&microsandbox_home_path).await?;
240
241    // Write PID to file
242    fs::write(&pid_file_path, pid.to_string())
243        .await
244        .map_err(|e| {
245            #[cfg(feature = "cli")]
246            term::finish_with_error(&start_server_sp);
247
248            MicrosandboxServerError::StartError(format!(
249                "failed to write PID file {}: {}",
250                pid_file_path.display(),
251                e
252            ))
253        })?;
254
255    #[cfg(feature = "cli")]
256    start_server_sp.finish();
257
258    if detach {
259        return Ok(());
260    }
261
262    // Set up signal handlers for graceful shutdown
263    let mut sigterm = tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate())
264        .map_err(|e| {
265            #[cfg(feature = "cli")]
266            term::finish_with_error(&start_server_sp);
267
268            MicrosandboxServerError::StartError(format!("failed to set up signal handlers: {}", e))
269        })?;
270
271    let mut sigint = tokio::signal::unix::signal(tokio::signal::unix::SignalKind::interrupt())
272        .map_err(|e| {
273            #[cfg(feature = "cli")]
274            term::finish_with_error(&start_server_sp);
275
276            MicrosandboxServerError::StartError(format!("failed to set up signal handlers: {}", e))
277        })?;
278
279    // Wait for either child process to exit or signal to be received
280    tokio::select! {
281        status = child.wait() => {
282            if !status.as_ref().map_or(false, |s| s.success()) {
283                tracing::error!(
284                    "child process — sandbox server — exited with status: {:?}",
285                    status
286                );
287
288                // Clean up PID file if process fails
289                clean(&pid_file_path).await?;
290
291                #[cfg(feature = "cli")]
292                term::finish_with_error(&start_server_sp);
293
294                return Err(MicrosandboxServerError::StartError(format!(
295                    "child process — sandbox server — failed with exit status: {:?}",
296                    status
297                )));
298            }
299
300            // Clean up PID file on successful exit
301            clean(&pid_file_path).await?;
302        }
303        _ = sigterm.recv() => {
304            tracing::info!("received SIGTERM signal");
305
306            // Send SIGTERM to child process
307            if let Err(e) = child.kill().await {
308                tracing::error!("failed to send SIGTERM to child process: {}", e);
309            }
310
311            // Wait for child to exit after sending signal
312            if let Err(e) = child.wait().await {
313                tracing::error!("error waiting for child after SIGTERM: {}", e);
314            }
315
316            // Clean up PID file after signal
317            clean(&pid_file_path).await?;
318
319            // Exit with a message
320            tracing::info!("server terminated by SIGTERM signal");
321        }
322        _ = sigint.recv() => {
323            tracing::info!("received SIGINT signal");
324
325            // Send SIGTERM to child process
326            if let Err(e) = child.kill().await {
327                tracing::error!("failed to send SIGTERM to child process: {}", e);
328            }
329
330            // Wait for child to exit after sending signal
331            if let Err(e) = child.wait().await {
332                tracing::error!("error waiting for child after SIGINT: {}", e);
333            }
334
335            // Clean up PID file after signal
336            clean(&pid_file_path).await?;
337
338            // Exit with a message
339            tracing::info!("server terminated by SIGINT signal");
340        }
341    }
342
343    Ok(())
344}
345
346/// Stop the sandbox server
347pub async fn stop() -> MicrosandboxServerResult<()> {
348    let microsandbox_home_path = env::get_microsandbox_home_path();
349    let pid_file_path = microsandbox_home_path.join(SERVER_PID_FILE);
350
351    #[cfg(feature = "cli")]
352    let stop_server_sp = term::create_spinner(STOP_SERVER_MSG.to_string(), None, None);
353
354    // Check if PID file exists
355    if !pid_file_path.exists() {
356        #[cfg(feature = "cli")]
357        term::finish_with_error(&stop_server_sp);
358
359        return Err(MicrosandboxServerError::StopError(
360            "server is not running (PID file not found)".to_string(),
361        ));
362    }
363
364    // Read PID from file
365    let pid_str = fs::read_to_string(&pid_file_path).await?;
366    let pid = pid_str.trim().parse::<i32>().map_err(|_| {
367        MicrosandboxServerError::StopError("invalid PID found in server.pid file".to_string())
368    })?;
369
370    // Send SIGTERM to the process
371    unsafe {
372        if libc::kill(pid, libc::SIGTERM) != 0 {
373            // If process doesn't exist, clean up PID file and return error
374            if std::io::Error::last_os_error().raw_os_error().unwrap() == libc::ESRCH {
375                // Delete only the PID file
376                clean(&pid_file_path).await?;
377
378                #[cfg(feature = "cli")]
379                term::finish_with_error(&stop_server_sp);
380
381                return Err(MicrosandboxServerError::StopError(
382                    "server process not found (stale PID file removed)".to_string(),
383                ));
384            }
385
386            #[cfg(feature = "cli")]
387            term::finish_with_error(&stop_server_sp);
388
389            return Err(MicrosandboxServerError::StopError(format!(
390                "failed to stop server process (PID: {})",
391                pid
392            )));
393        }
394    }
395
396    // Clean up just the PID file
397    clean(&pid_file_path).await?;
398
399    #[cfg(feature = "cli")]
400    stop_server_sp.finish();
401
402    tracing::info!("stopped sandbox server process (PID: {})", pid);
403
404    Ok(())
405}
406
407/// Generate a new API key (JWT token)
408pub async fn keygen(
409    expire: Option<Duration>,
410    namespace: String,
411) -> MicrosandboxServerResult<String> {
412    let microsandbox_home_path = env::get_microsandbox_home_path();
413    let key_file_path = microsandbox_home_path.join(SERVER_KEY_FILE);
414
415    #[cfg(feature = "cli")]
416    let keygen_sp = term::create_spinner(KEYGEN_MSG.to_string(), None, None);
417
418    // Check if server key file exists
419    if !key_file_path.exists() {
420        #[cfg(feature = "cli")]
421        term::finish_with_error(&keygen_sp);
422
423        return Err(MicrosandboxServerError::KeyGenError(
424            "Server key file not found. Make sure the server is running in secure mode."
425                .to_string(),
426        ));
427    }
428
429    // Read the server key
430    let server_key = fs::read_to_string(&key_file_path).await.map_err(|e| {
431        #[cfg(feature = "cli")]
432        term::finish_with_error(&keygen_sp);
433
434        MicrosandboxServerError::KeyGenError(format!(
435            "Failed to read server key file {}: {}",
436            key_file_path.display(),
437            e
438        ))
439    })?;
440
441    // Determine token expiration (default: 24 hours)
442    let expire = expire.unwrap_or(Duration::hours(24));
443
444    // Generate JWT token with the specified expiration
445    let now = Utc::now();
446    let expiry = now + expire;
447
448    let claims = Claims {
449        exp: expiry.timestamp() as u64,
450        iat: now.timestamp() as u64,
451        namespace,
452    };
453
454    // Encode the token
455    let jwt_token = jsonwebtoken::encode(
456        &Header::default(),
457        &claims,
458        &EncodingKey::from_secret(server_key.as_bytes()),
459    )
460    .map_err(|e| {
461        #[cfg(feature = "cli")]
462        term::finish_with_error(&keygen_sp);
463
464        MicrosandboxServerError::KeyGenError(format!("Failed to generate token: {}", e))
465    })?;
466
467    // Convert the JWT token to our custom API key format
468    let custom_token = convert_jwt_to_api_key(&jwt_token)?;
469
470    // Store the token information for output
471    let token_str = custom_token.clone();
472    let expiry_str = expiry.to_rfc3339();
473
474    #[cfg(feature = "cli")]
475    keygen_sp.finish();
476
477    tracing::info!(
478        "Generated API token with namespace {} and expiry {}",
479        claims.namespace,
480        expiry_str
481    );
482
483    #[cfg(feature = "cli")]
484    {
485        println!("Token: {}", console::style(&token_str).cyan());
486        println!("Token expires: {}", console::style(&expiry_str).cyan());
487        println!("Namespace: {}", console::style(&claims.namespace).cyan());
488    }
489
490    Ok(token_str)
491}
492
493/// Clean up the PID file
494pub async fn clean(pid_file_path: &PathBuf) -> MicrosandboxServerResult<()> {
495    // Clean up PID file
496    if pid_file_path.exists() {
497        fs::remove_file(pid_file_path).await?;
498        tracing::info!("removed server PID file at {}", pid_file_path.display());
499    }
500
501    Ok(())
502}
503
504//--------------------------------------------------------------------------------------------------
505// Functions: Helpers
506//--------------------------------------------------------------------------------------------------
507
508/// Generate a random key for JWT token signing
509fn generate_random_key() -> String {
510    rand::rng()
511        .sample_iter(&Alphanumeric)
512        .take(SERVER_KEY_LENGTH)
513        .map(char::from)
514        .collect()
515}
516
517/// Convert a standard JWT token to our custom API key format
518/// Takes a standard JWT token (<header>.<payload>.<signature>) and returns
519/// our custom API key format (<API_KEY_PREFIX><full_jwt_token>)
520pub fn convert_jwt_to_api_key(jwt_token: &str) -> MicrosandboxServerResult<String> {
521    // Create custom API key format: API_KEY_PREFIX + full JWT token
522    Ok(format!("{}{}", API_KEY_PREFIX, jwt_token))
523}