From 2826598f688cb1b18cb54cc905df1f47389e4b53 Mon Sep 17 00:00:00 2001 From: Cylae <13425054+Cylae@users.noreply.github.com> Date: Mon, 16 Feb 2026 14:22:47 +0000 Subject: [PATCH] Refactor Config and Users for Async/Caching, Fix Hardware, Update README - **Config**: Implemented `CONFIG_CACHE` with 500ms throttling and async methods `enable_service_async`/`disable_service_async`. - **Users**: Implemented `USERS_CACHE` with 500ms throttling and async methods `add_user_async`, `delete_user_async`, etc., using `spawn_blocking` for CPU/IO tasks. - **Web Interface**: Refactored to use static async methods from `core`, removing local caches and `block_in_place`. - **Hardware**: Added `sys.refresh_disks()` to `detect()` for accurate disk stats. - **Project**: Bumped version to 1.0.8 and updated `README.md`. --- README.md | 2 +- server_manager/Cargo.lock | 2 +- server_manager/Cargo.toml | 2 +- server_manager/src/core/config.rs | 87 +++++++- server_manager/src/core/hardware.rs | 1 + server_manager/src/core/users.rs | 330 +++++++++++++++++++++++++--- server_manager/src/interface/web.rs | 134 +---------- 7 files changed, 384 insertions(+), 174 deletions(-) diff --git a/README.md b/README.md index 6187960..1d57e34 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # Server Manager - Next-Gen Media Server Orchestrator 🚀 -![Server Manager Banner](https://img.shields.io/badge/Status-Tested-brightgreen) ![Version](https://img.shields.io/badge/Version-1.0.7-blue) ![Rust](https://img.shields.io/badge/Built%20With-Rust-orange) ![Docker](https://img.shields.io/badge/Powered%20By-Docker-blue) +![Server Manager Banner](https://img.shields.io/badge/Status-Tested-brightgreen) ![Version](https://img.shields.io/badge/Version-1.0.8-blue) ![Rust](https://img.shields.io/badge/Built%20With-Rust-orange) ![Docker](https://img.shields.io/badge/Powered%20By-Docker-blue) **Server Manager** is a powerful and intelligent tool written in Rust to deploy, manage, and optimize a complete personal media and cloud server stack. It detects your hardware and automatically configures 28 Docker services for optimal performance. diff --git a/server_manager/Cargo.lock b/server_manager/Cargo.lock index 9d73bac..88fc67f 100644 --- a/server_manager/Cargo.lock +++ b/server_manager/Cargo.lock @@ -1288,7 +1288,7 @@ dependencies = [ [[package]] name = "server_manager" -version = "1.0.7" +version = "1.0.8" dependencies = [ "anyhow", "async-trait", diff --git a/server_manager/Cargo.toml b/server_manager/Cargo.toml index 475f80e..59a6547 100644 --- a/server_manager/Cargo.toml +++ b/server_manager/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "server_manager" -version = "1.0.7" +version = "1.0.8" edition = "2021" [dependencies] diff --git a/server_manager/src/core/config.rs b/server_manager/src/core/config.rs index c57c2a7..42a4cb8 100644 --- a/server_manager/src/core/config.rs +++ b/server_manager/src/core/config.rs @@ -5,13 +5,14 @@ use std::collections::HashSet; use std::fs; use std::path::Path; use std::sync::OnceLock; -use std::time::SystemTime; +use std::time::{Duration, SystemTime}; use tokio::sync::RwLock; #[derive(Debug, Clone)] struct CachedConfig { config: Config, last_mtime: Option, + last_check: Option, } static CONFIG_CACHE: OnceLock> = OnceLock::new(); @@ -42,19 +43,17 @@ impl Config { RwLock::new(CachedConfig { config: Config::default(), last_mtime: None, + last_check: None, }) }); - // Fast path: Optimistic read + // Fast path: Optimistic read with throttle { let guard = cache.read().await; - if let Some(cached_mtime) = guard.last_mtime { - // Check if file still matches - if let Ok(metadata) = tokio::fs::metadata("config.yaml").await { - if let Ok(modified) = metadata.modified() { - if modified == cached_mtime { - return Ok(guard.config.clone()); - } + if let Some(last_check) = guard.last_check { + if let Ok(elapsed) = SystemTime::now().duration_since(last_check) { + if elapsed < Duration::from_millis(500) { + return Ok(guard.config.clone()); } } } @@ -63,8 +62,18 @@ impl Config { // Slow path: Update cache let mut guard = cache.write().await; + // Check throttle again under write lock + if let Some(last_check) = guard.last_check { + if let Ok(elapsed) = SystemTime::now().duration_since(last_check) { + if elapsed < Duration::from_millis(500) { + return Ok(guard.config.clone()); + } + } + } + // Check metadata again (double-checked locking pattern) let metadata_res = tokio::fs::metadata("config.yaml").await; + guard.last_check = Some(SystemTime::now()); match metadata_res { Ok(metadata) => { @@ -124,4 +133,64 @@ impl Config { info!("Disabled service: {}", service_name); } } + + pub async fn enable_service_async(service_name: &str) -> Result<()> { + Self::update_service_async(service_name, true).await + } + + pub async fn disable_service_async(service_name: &str) -> Result<()> { + Self::update_service_async(service_name, false).await + } + + async fn update_service_async(service_name: &str, enable: bool) -> Result<()> { + let cache = CONFIG_CACHE.get_or_init(|| { + RwLock::new(CachedConfig { + config: Config::default(), + last_mtime: None, + last_check: None, + }) + }); + + let mut guard = cache.write().await; + + // Sync with disk first + let metadata_res = tokio::fs::metadata("config.yaml").await; + if let Ok(metadata) = metadata_res { + let modified = metadata.modified().unwrap_or(SystemTime::now()); + let reload = match guard.last_mtime { + Some(cached) => modified != cached, + None => true, + }; + + if reload { + if let Ok(content) = tokio::fs::read_to_string("config.yaml").await { + if !content.trim().is_empty() { + if let Ok(cfg) = serde_yaml_ng::from_str::(&content) { + guard.config = cfg; + } + } + } + } + } + + // Modify + if enable { + guard.config.enable_service(service_name); + } else { + guard.config.disable_service(service_name); + } + + // Save (Async) + let content = serde_yaml_ng::to_string(&guard.config)?; + tokio::fs::write("config.yaml", content) + .await + .context("Failed to write config.yaml")?; + + // Update mtime + if let Ok(metadata) = tokio::fs::metadata("config.yaml").await { + guard.last_mtime = metadata.modified().ok(); + } + + Ok(()) + } } diff --git a/server_manager/src/core/hardware.rs b/server_manager/src/core/hardware.rs index 09f3ff2..9acbf31 100644 --- a/server_manager/src/core/hardware.rs +++ b/server_manager/src/core/hardware.rs @@ -31,6 +31,7 @@ impl HardwareInfo { sys.refresh_memory(); sys.refresh_cpu(); sys.refresh_disks_list(); + sys.refresh_disks(); let total_memory = sys.total_memory(); // Bytes let ram_gb = total_memory / 1024 / 1024 / 1024; diff --git a/server_manager/src/core/users.rs b/server_manager/src/core/users.rs index 41870d0..22ca3d8 100644 --- a/server_manager/src/core/users.rs +++ b/server_manager/src/core/users.rs @@ -6,7 +6,19 @@ use nix::unistd::Uid; use serde::{Deserialize, Serialize}; use std::collections::HashMap; use std::fs; -use std::path::Path; +use std::path::{Path, PathBuf}; +use std::sync::OnceLock; +use std::time::{Duration, SystemTime}; +use tokio::sync::RwLock; + +#[derive(Debug, Clone)] +struct CachedUsers { + manager: UserManager, + last_mtime: Option, + last_check: Option, +} + +static USERS_CACHE: OnceLock> = OnceLock::new(); #[derive(Debug, Serialize, Deserialize, Clone, PartialEq)] pub enum Role { @@ -29,27 +41,123 @@ pub struct UserManager { } impl UserManager { + fn get_active_path() -> PathBuf { + let path = PathBuf::from("users.yaml"); + let fallback_path = PathBuf::from("/opt/server_manager/users.yaml"); + + if fallback_path.exists() { + fallback_path + } else if path.exists() { + path + } else if Path::new("/opt/server_manager").exists() { + fallback_path + } else { + path + } + } + pub async fn load_async() -> Result { - tokio::task::spawn_blocking(Self::load).await? + let cache = USERS_CACHE.get_or_init(|| { + RwLock::new(CachedUsers { + manager: UserManager::default(), + last_mtime: None, + last_check: None, + }) + }); + + // Fast path + { + let guard = cache.read().await; + if let Some(last_check) = guard.last_check { + if let Ok(elapsed) = SystemTime::now().duration_since(last_check) { + if elapsed < Duration::from_millis(500) { + return Ok(guard.manager.clone()); + } + } + } + } + + // Slow path + let mut guard = cache.write().await; + if let Some(last_check) = guard.last_check { + if let Ok(elapsed) = SystemTime::now().duration_since(last_check) { + if elapsed < Duration::from_millis(500) { + return Ok(guard.manager.clone()); + } + } + } + + Self::sync_cache(&mut guard).await?; + guard.last_check = Some(SystemTime::now()); + Ok(guard.manager.clone()) + } + + async fn sync_cache(guard: &mut CachedUsers) -> Result<()> { + let path = Self::get_active_path(); + let metadata_res = tokio::fs::metadata(&path).await; + + match metadata_res { + Ok(metadata) => { + let modified = metadata.modified().unwrap_or(SystemTime::now()); + let reload = match guard.last_mtime { + Some(cached) => modified != cached, + None => true, + }; + + if reload { + if let Ok(content) = tokio::fs::read_to_string(&path).await { + if content.trim().is_empty() { + guard.manager = UserManager::default(); + } else { + guard.manager = serde_yaml_ng::from_str(&content) + .unwrap_or_else(|_| UserManager::default()); + } + } + guard.last_mtime = Some(modified); + } + } + Err(_) => { + // If file doesn't exist, we might need to create default admin + // But only if we are initializing? + // For simplicity, if no file, we default. + // But we must handle the "first run" logic of creating admin user. + if guard.manager.users.is_empty() { + // Logic to create default admin if completely empty + // This is slightly tricky inside sync_cache which is a helper. + // But if we return empty manager, the caller might see it empty. + // Let's handle it here or in load(). + // To match previous load() logic: + let pass = "admin"; + // Blocking hash + let hash = tokio::task::spawn_blocking(move || hash(pass, DEFAULT_COST)) + .await??; + guard.manager.users.insert( + "admin".to_string(), + User { + username: "admin".to_string(), + password_hash: hash, + role: Role::Admin, + quota_gb: None, + }, + ); + // Save immediately + let content = serde_yaml_ng::to_string(&guard.manager)?; + tokio::fs::write(&path, content).await?; + if let Ok(m) = tokio::fs::metadata(&path).await { + guard.last_mtime = m.modified().ok(); + } + info!("Default user 'admin' created with password 'admin'. CHANGE THIS IMMEDIATELY!"); + } + } + } + Ok(()) } pub fn load() -> Result { - // Try CWD or /opt/server_manager - let path = Path::new("users.yaml"); - let fallback_path = Path::new("/opt/server_manager/users.yaml"); - - // Priority: /opt/server_manager/users.yaml > ./users.yaml - // This aligns with save() behavior which prefers /opt if available. - let load_path = if fallback_path.exists() { - Some(fallback_path) - } else if path.exists() { - Some(path) - } else { - None - }; + let path = Self::get_active_path(); - let mut manager = if let Some(p) = load_path { - let content = fs::read_to_string(p).context("Failed to read users.yaml")?; + let mut manager = if path.exists() { + let content = fs::read_to_string(&path).context("Failed to read users.yaml")?; if content.trim().is_empty() { UserManager::default() } else { @@ -59,16 +167,8 @@ impl UserManager { UserManager::default() }; - // Ensure default admin exists if no users if manager.users.is_empty() { info!("No users found. Creating default 'admin' user."); - // We use a generated secret for the initial password if secrets exist, - // otherwise generate one. - // Better: use 'admin' / 'admin' but WARN, or generate random. - // Let's generate a random one and print it, safer. - // Re-using secrets generation logic if possible, or just simple random. - // For simplicity in this context, let's look for a stored password or default to 'admin' and log a warning. - let pass = "admin"; let hash = hash(pass, DEFAULT_COST)?; manager.users.insert( @@ -80,7 +180,8 @@ impl UserManager { quota_gb: None, }, ); - manager.save()?; + let content = serde_yaml_ng::to_string(&manager)?; + fs::write(&path, content)?; info!("Default user 'admin' created with password 'admin'. CHANGE THIS IMMEDIATELY!"); } @@ -88,18 +189,179 @@ impl UserManager { } pub fn save(&self) -> Result<()> { - // Prefer saving to /opt/server_manager if it exists/is writable, else CWD - let target = if Path::new("/opt/server_manager").exists() { - Path::new("/opt/server_manager/users.yaml") - } else { - Path::new("users.yaml") - }; - + let path = Self::get_active_path(); let content = serde_yaml_ng::to_string(self)?; - fs::write(target, content).context("Failed to write users.yaml")?; + fs::write(path, content).context("Failed to write users.yaml")?; + Ok(()) + } + + pub async fn add_user_async( + username: String, + password: String, + role: Role, + quota_gb: Option, + ) -> Result<()> { + let cache = USERS_CACHE.get_or_init(|| { + RwLock::new(CachedUsers { + manager: UserManager::default(), + last_mtime: None, + last_check: None, + }) + }); + + let mut guard = cache.write().await; + Self::sync_cache(&mut guard).await?; + + if guard.manager.users.contains_key(&username) { + return Err(anyhow!("User already exists")); + } + + let u_clone = username.clone(); + let p_clone = password.clone(); + let q_clone = quota_gb; + + tokio::task::spawn_blocking(move || { + if Uid::effective().is_root() { + system::create_system_user(&u_clone, &p_clone)?; + if let Some(gb) = q_clone { + system::set_system_quota(&u_clone, gb)?; + } + } else { + warn!( + "Not running as root. Skipping system user creation for '{}'.", + u_clone + ); + } + Ok::<(), anyhow::Error>(()) + }) + .await??; + + let hash = tokio::task::spawn_blocking(move || hash(&password, DEFAULT_COST)).await??; + + guard.manager.users.insert( + username.clone(), + User { + username: username.clone(), + password_hash: hash, + role, + quota_gb, + }, + ); + + let content = serde_yaml_ng::to_string(&guard.manager)?; + let path = Self::get_active_path(); + tokio::fs::write(&path, content).await?; + + if let Ok(metadata) = tokio::fs::metadata(&path).await { + guard.last_mtime = metadata.modified().ok(); + } + + Ok(()) + } + + pub async fn delete_user_async(username: String) -> Result<()> { + let cache = USERS_CACHE.get_or_init(|| { + RwLock::new(CachedUsers { + manager: UserManager::default(), + last_mtime: None, + last_check: None, + }) + }); + + let mut guard = cache.write().await; + Self::sync_cache(&mut guard).await?; + + if !guard.manager.users.contains_key(&username) { + return Err(anyhow!("User not found")); + } + if username == "admin" && guard.manager.users.len() == 1 { + return Err(anyhow!("Cannot delete the last admin user")); + } + + let u_clone = username.clone(); + tokio::task::spawn_blocking(move || { + if Uid::effective().is_root() { + system::delete_system_user(&u_clone)?; + } else { + warn!( + "Not running as root. Skipping system user deletion for '{}'.", + u_clone + ); + } + Ok::<(), anyhow::Error>(()) + }) + .await??; + + guard.manager.users.remove(&username); + + let content = serde_yaml_ng::to_string(&guard.manager)?; + let path = Self::get_active_path(); + tokio::fs::write(&path, content).await?; + + if let Ok(metadata) = tokio::fs::metadata(&path).await { + guard.last_mtime = metadata.modified().ok(); + } + Ok(()) } + pub async fn list_users_async() -> Result> { + let manager = Self::load_async().await?; + Ok(manager.users.values().cloned().collect()) + } + + pub async fn get_user_async(username: &str) -> Option { + if let Ok(manager) = Self::load_async().await { + manager.users.get(username).cloned() + } else { + None + } + } + + pub async fn update_password_async(username: String, new_password: String) -> Result<()> { + let cache = USERS_CACHE.get_or_init(|| { + RwLock::new(CachedUsers { + manager: UserManager::default(), + last_mtime: None, + last_check: None, + }) + }); + + let mut guard = cache.write().await; + Self::sync_cache(&mut guard).await?; + + if let Some(user) = guard.manager.users.get_mut(&username) { + let u_clone = username.clone(); + let p_clone = new_password.clone(); + + tokio::task::spawn_blocking(move || { + if Uid::effective().is_root() { + system::set_system_user_password(&u_clone, &p_clone)?; + } else { + warn!( + "Not running as root. Skipping system password update for '{}'.", + u_clone + ); + } + Ok::<(), anyhow::Error>(()) + }).await??; + + let hash = tokio::task::spawn_blocking(move || hash(&new_password, DEFAULT_COST)).await??; + user.password_hash = hash; + + let content = serde_yaml_ng::to_string(&guard.manager)?; + let path = Self::get_active_path(); + tokio::fs::write(&path, content).await?; + + if let Ok(metadata) = tokio::fs::metadata(&path).await { + guard.last_mtime = metadata.modified().ok(); + } + Ok(()) + } else { + Err(anyhow!("User not found")) + } + } + pub fn add_user( &mut self, username: &str, diff --git a/server_manager/src/interface/web.rs b/server_manager/src/interface/web.rs index 1f677c3..f853c0c 100644 --- a/server_manager/src/interface/web.rs +++ b/server_manager/src/interface/web.rs @@ -17,7 +17,6 @@ use std::time::SystemTime; use sysinfo::{CpuExt, DiskExt, System, SystemExt}; use time::Duration; use tokio::process::Command; -use tokio::sync::RwLock; use tower_sessions::{Expiry, MemoryStore, Session, SessionManagerLayer}; #[derive(Serialize, Deserialize, Clone)] @@ -28,98 +27,20 @@ struct SessionUser { const SESSION_KEY: &str = "user"; -struct CachedConfig { - config: Config, - last_modified: Option, -} - -struct CachedUsers { - manager: UserManager, - last_modified: Option, -} - struct AppState { system: Mutex, last_system_refresh: Mutex, - config_cache: RwLock, - users_cache: RwLock, } type SharedState = Arc; impl AppState { async fn get_config(&self) -> Config { - // Fast path: check metadata - let current_mtime = tokio::fs::metadata("config.yaml") - .await - .and_then(|m| m.modified()) - .ok(); - - { - let cache = self.config_cache.read().await; - if cache.last_modified == current_mtime { - return cache.config.clone(); - } - } - - // Slow path: reload - let mut cache = self.config_cache.write().await; - - // Re-check mtime under write lock to avoid race - let current_mtime_2 = tokio::fs::metadata("config.yaml") - .await - .and_then(|m| m.modified()) - .ok(); - - if cache.last_modified == current_mtime_2 { - return cache.config.clone(); - } - - if let Ok(cfg) = Config::load_async().await { - cache.config = cfg; - cache.last_modified = current_mtime_2; - } - - cache.config.clone() + Config::load_async().await.unwrap_or_default() } async fn get_users(&self) -> UserManager { - // Determine path logic (matches UserManager::load) - let path = std::path::Path::new("users.yaml"); - let fallback_path = std::path::Path::new("/opt/server_manager/users.yaml"); - let file_path = if path.exists() { path } else { fallback_path }; - - // Fast path: check metadata - let current_mtime = tokio::fs::metadata(file_path).await - .and_then(|m| m.modified()) - .ok(); - - { - let cache = self.users_cache.read().await; - // If mtime matches (or both None), return cached - if cache.last_modified == current_mtime { - return cache.manager.clone(); - } - } - - // Slow path: reload - let mut cache = self.users_cache.write().await; - - // Re-check mtime under write lock - let current_mtime_2 = tokio::fs::metadata(file_path).await - .and_then(|m| m.modified()) - .ok(); - - if cache.last_modified == current_mtime_2 { - return cache.manager.clone(); - } - - if let Ok(mgr) = UserManager::load_async().await { - cache.manager = mgr; - cache.last_modified = current_mtime_2; - } - - cache.manager.clone() + UserManager::load_async().await.unwrap_or_default() } } @@ -134,32 +55,9 @@ pub async fn start_server(port: u16) -> anyhow::Result<()> { let mut sys = System::new_all(); sys.refresh_all(); - let initial_config = Config::load().unwrap_or_default(); - let initial_config_mtime = std::fs::metadata("config.yaml") - .ok() - .and_then(|m| m.modified().ok()); - - let initial_users = UserManager::load().unwrap_or_default(); - let initial_users_mtime = std::fs::metadata("users.yaml") - .ok() - .and_then(|m| m.modified().ok()) - .or_else(|| { - std::fs::metadata("/opt/server_manager/users.yaml") - .ok() - .and_then(|m| m.modified().ok()) - }); - let app_state = Arc::new(AppState { system: Mutex::new(sys), last_system_refresh: Mutex::new(SystemTime::now()), - config_cache: RwLock::new(CachedConfig { - config: initial_config, - last_modified: initial_config_mtime, - }), - users_cache: RwLock::new(CachedUsers { - manager: initial_users, - last_modified: initial_users_mtime, - }), }); let app = Router::new() @@ -605,7 +503,7 @@ struct AddUserPayload { quota: Option, } -async fn add_user_handler(State(state): State, session: Session, Form(payload): Form) -> impl IntoResponse { +async fn add_user_handler(State(_state): State, session: Session, Form(payload): Form) -> impl IntoResponse { let session_user: SessionUser = match session.get(SESSION_KEY).await { Ok(Some(u)) => u, _ => return Redirect::to("/login").into_response(), @@ -626,29 +524,19 @@ async fn add_user_handler(State(state): State, session: Session, Fo None => None, }; - let mut cache = state.users_cache.write().await; - let res = tokio::task::block_in_place(|| { - cache.manager.add_user(&payload.username, &payload.password, role_enum, quota_val) - }); + let res = UserManager::add_user_async(payload.username.clone(), payload.password, role_enum, quota_val).await; if let Err(e) = res { error!("Failed to add user: {}", e); // In a real app we'd flash a message. Here just redirect. } else { info!("User {} added via Web UI by {}", payload.username, session_user.username); - // Update mtime to prevent unnecessary reload - let path = std::path::Path::new("users.yaml"); - let fallback_path = std::path::Path::new("/opt/server_manager/users.yaml"); - let file_path = if path.exists() { path } else { fallback_path }; - if let Ok(m) = std::fs::metadata(file_path) { - cache.last_modified = m.modified().ok(); - } } Redirect::to("/users").into_response() } -async fn delete_user_handler(State(state): State, session: Session, Path(username): Path) -> impl IntoResponse { +async fn delete_user_handler(State(_state): State, session: Session, Path(username): Path) -> impl IntoResponse { let session_user: SessionUser = match session.get(SESSION_KEY).await { Ok(Some(u)) => u, _ => return Redirect::to("/login").into_response(), @@ -658,22 +546,12 @@ async fn delete_user_handler(State(state): State, session: Session, return (StatusCode::FORBIDDEN, "Access Denied").into_response(); } - let mut cache = state.users_cache.write().await; - let res = tokio::task::block_in_place(|| { - cache.manager.delete_user(&username) - }); + let res = UserManager::delete_user_async(username.clone()).await; if let Err(e) = res { error!("Failed to delete user: {}", e); } else { info!("User {} deleted via Web UI by {}", username, session_user.username); - // Update mtime to prevent unnecessary reload - let path = std::path::Path::new("users.yaml"); - let fallback_path = std::path::Path::new("/opt/server_manager/users.yaml"); - let file_path = if path.exists() { path } else { fallback_path }; - if let Ok(m) = std::fs::metadata(file_path) { - cache.last_modified = m.modified().ok(); - } } Redirect::to("/users").into_response()