diff --git a/README.md b/README.md index 6187960..2601070 100644 --- a/README.md +++ b/README.md @@ -165,16 +165,16 @@ Here is the matrix of deployed services: | | Bazarr | 6767 (Localhost) | `http://localhost:6767` | Subtitles | | | Prowlarr | 9696 (Localhost) | `http://localhost:9696` | Torrent Indexers | | | Jackett | 9117 (Localhost) | `http://localhost:9117` | Indexer Proxy | -| **Download** | QBittorrent | 8080 (Localhost) | `http://localhost:8080` | Torrent Client | +| **Download** | QBittorrent | 8080 (Localhost), 6881 | `http://localhost:8080` | Torrent Client | | **Apps** | Nextcloud | 4443 (Localhost) | `https://localhost:4443` | Personal Cloud | | | Vaultwarden | 8001 (Localhost) | `http://localhost:8001` | Password Manager | | | Filebrowser | 8002 (Localhost) | `http://localhost:8002` | Web File Manager | | | Yourls | 8003 (Localhost) | `http://localhost:8003` | URL Shortener | | | GLPI | 8088 (Localhost) | `http://localhost:8088` | IT Asset Management | -| | Gitea | 3000 (Localhost) | `http://localhost:3000` | Self-hosted Git | +| | Gitea | 3000 (Localhost), 2222 | `http://localhost:3000` | Self-hosted Git | | | Roundcube | 8090 (Localhost) | `http://localhost:8090` | Webmail | | | Mailserver | 25, 143, 587, 993 | - | Full Mail Server | -| | Syncthing | 8384 (Localhost), 22000 | `http://localhost:8384` | File Synchronization | +| | Syncthing | 8384 (Localhost), 22000, 21027 | `http://localhost:8384` | File Synchronization | --- @@ -319,16 +319,16 @@ Voici la matrice des services déployés : | | Bazarr | 6767 (Localhost) | `http://localhost:6767` | Sous-titres | | | Prowlarr | 9696 (Localhost) | `http://localhost:9696` | Indexeurs Torrent | | | Jackett | 9117 (Localhost) | `http://localhost:9117` | Proxy Indexeurs | -| **Download** | QBittorrent | 8080 (Localhost) | `http://localhost:8080` | Client Torrent | +| **Download** | QBittorrent | 8080 (Localhost), 6881 | `http://localhost:8080` | Client Torrent | | **Apps** | Nextcloud | 4443 (Localhost) | `https://localhost:4443` | Personal Cloud | | | Vaultwarden | 8001 (Localhost) | `http://localhost:8001` | Password Manager | | | Filebrowser | 8002 (Localhost) | `http://localhost:8002` | Web File Manager | | | Yourls | 8003 (Localhost) | `http://localhost:8003` | URL Shortener | | | GLPI | 8088 (Localhost) | `http://localhost:8088` | IT Asset Management | -| | Gitea | 3000 (Localhost) | `http://localhost:3000` | Self-hosted Git | +| | Gitea | 3000 (Localhost), 2222 | `http://localhost:3000` | Self-hosted Git | | | Roundcube | 8090 (Localhost) | `http://localhost:8090` | Webmail | | | Mailserver | 25, 143, 587, 993 | - | Full Mail Server | -| | Syncthing | 8384 (Localhost), 22000 | `http://localhost:8384` | Synchronisation de Fichiers | +| | Syncthing | 8384 (Localhost), 22000, 21027 | `http://localhost:8384` | Synchronisation de Fichiers | --- diff --git a/server_manager/src/core/config.rs b/server_manager/src/core/config.rs index c57c2a7..a5f4582 100644 --- a/server_manager/src/core/config.rs +++ b/server_manager/src/core/config.rs @@ -12,6 +12,7 @@ use tokio::sync::RwLock; struct CachedConfig { config: Config, last_mtime: Option, + last_check: SystemTime, } static CONFIG_CACHE: OnceLock> = OnceLock::new(); @@ -42,20 +43,16 @@ impl Config { RwLock::new(CachedConfig { config: Config::default(), last_mtime: None, + last_check: std::time::UNIX_EPOCH, }) }); - // Fast path: Optimistic read + // Fast path: Optimistic read with throttle check { 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 Ok(elapsed) = SystemTime::now().duration_since(guard.last_check) { + if elapsed.as_millis() < 500 { + return Ok(guard.config.clone()); } } } @@ -63,7 +60,17 @@ impl Config { // Slow path: Update cache let mut guard = cache.write().await; - // Check metadata again (double-checked locking pattern) + // Double check time under write lock + if let Ok(elapsed) = SystemTime::now().duration_since(guard.last_check) { + if elapsed.as_millis() < 500 { + return Ok(guard.config.clone()); + } + } + + // Update check time + guard.last_check = SystemTime::now(); + + // Check metadata let metadata_res = tokio::fs::metadata("config.yaml").await; match metadata_res { diff --git a/server_manager/src/core/users.rs b/server_manager/src/core/users.rs index 41870d0..1e9566f 100644 --- a/server_manager/src/core/users.rs +++ b/server_manager/src/core/users.rs @@ -7,6 +7,9 @@ use serde::{Deserialize, Serialize}; use std::collections::HashMap; use std::fs; use std::path::Path; +use std::sync::OnceLock; +use std::time::SystemTime; +use tokio::sync::RwLock; #[derive(Debug, Serialize, Deserialize, Clone, PartialEq)] pub enum Role { @@ -23,6 +26,15 @@ pub struct User { pub quota_gb: Option, } +#[derive(Debug, Clone)] +struct CachedUsers { + manager: UserManager, + last_mtime: Option, + last_check: SystemTime, +} + +static USERS_CACHE: OnceLock> = OnceLock::new(); + #[derive(Debug, Serialize, Deserialize, Default, Clone)] pub struct UserManager { users: HashMap, @@ -30,7 +42,78 @@ pub struct UserManager { impl UserManager { 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: std::time::UNIX_EPOCH, + }) + }); + + // Fast path + { + let guard = cache.read().await; + if let Ok(elapsed) = SystemTime::now().duration_since(guard.last_check) { + if elapsed.as_millis() < 500 { + return Ok(guard.manager.clone()); + } + } + } + + // Slow path + let mut guard = cache.write().await; + + if let Ok(elapsed) = SystemTime::now().duration_since(guard.last_check) { + if elapsed.as_millis() < 500 { + return Ok(guard.manager.clone()); + } + } + + guard.last_check = SystemTime::now(); + + // Path logic (duplicated from load to check metadata) + let path = Path::new("users.yaml"); + let fallback_path = Path::new("/opt/server_manager/users.yaml"); + let check_path = if fallback_path.exists() { + Some(fallback_path) + } else if path.exists() { + Some(path) + } else { + None + }; + + if let Some(p) = check_path { + // Check metadata + let metadata = tokio::fs::metadata(p).await + .context("Failed to get users.yaml metadata")?; + let modified = metadata.modified().unwrap_or(SystemTime::now()); + + if let Some(cached_mtime) = guard.last_mtime { + if modified == cached_mtime { + // No change, return cached + return Ok(guard.manager.clone()); + } + } + + // Changed or first load -> Reload + let manager = tokio::task::spawn_blocking(Self::load).await??; + guard.manager = manager.clone(); + guard.last_mtime = Some(modified); + Ok(manager) + } else { + // No file -> Load default (will create file) + let manager = tokio::task::spawn_blocking(Self::load).await??; + guard.manager = manager.clone(); + + // Try to get mtime of newly created file + if let Ok(meta) = tokio::fs::metadata(path).await { + guard.last_mtime = meta.modified().ok(); + } else if let Ok(meta) = tokio::fs::metadata(fallback_path).await { + guard.last_mtime = meta.modified().ok(); + } + + Ok(manager) + } } pub fn load() -> Result { @@ -212,6 +295,13 @@ impl UserManager { pub fn list_users(&self) -> Vec<&User> { self.users.values().collect() } + + pub async fn invalidate_cache_async() { + if let Some(cache) = USERS_CACHE.get() { + let mut guard = cache.write().await; + guard.last_check = std::time::UNIX_EPOCH; + } + } } #[cfg(test)] diff --git a/server_manager/src/interface/cli.rs b/server_manager/src/interface/cli.rs index bdadad9..1151f05 100644 --- a/server_manager/src/interface/cli.rs +++ b/server_manager/src/interface/cli.rs @@ -275,10 +275,19 @@ fn print_deployment_summary(secrets: &secrets::Secrets) { append_row("Roundcube", "http://:8090", "-", "Login with Mail creds"); append_row("MailServer", "PORTS: 25, 143...", "CLI", "docker exec -ti mailserver setup ..."); append_row("Plex", "http://:32400/web", "-", "Follow Web Setup"); + append_row("Jellyfin", "http://:8096", "-", "Follow Web Setup"); append_row("ArrStack", "http://:8989 (Sonarr)", "-", "No auth by default"); + append_row("QBittorrent", "http://:8080", "admin", "adminadmin"); + append_row("Filebrowser", "http://:8002", "admin", "admin"); + append_row("Syncthing", "http://:8384", "-", "No auth by default"); + append_row("Uptime Kuma", "http://:3001", "-", "Create Admin Account"); + append_row("Netdata", "http://:19999", "-", "No auth by default"); + append_row("Tautulli", "http://:8181", "-", "Follow Web Setup"); + append_row("Overseerr", "http://:5055", "-", "Follow Web Setup"); summary.push_str("=================================================================================\n\n"); - summary.push_str("NOTE: Replace with your server's IP address."); + summary.push_str("NOTE: Replace with your server's IP address. Some services bound to localhost\n"); + summary.push_str(" must be accessed via SSH Tunnel or Nginx Proxy Manager."); println!("{}", summary); diff --git a/server_manager/src/interface/web.rs b/server_manager/src/interface/web.rs index 1f677c3..609f610 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,101 +27,13 @@ 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() - } - - 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() - } -} - pub async fn start_server(port: u16) -> anyhow::Result<()> { // Session setup let session_store = MemoryStore::default(); @@ -134,32 +45,13 @@ 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()) - }); + // Ensure initial load/cache population + let _ = Config::load_async().await; + let _ = UserManager::load_async().await; 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, - }), + last_system_refresh: Mutex::new(SystemTime::UNIX_EPOCH), // Force immediate refresh }); let app = Router::new() @@ -226,9 +118,15 @@ struct LoginPayload { password: String, } -async fn login_handler(State(state): State, session: Session, Form(payload): Form) -> impl IntoResponse { +async fn login_handler(session: Session, Form(payload): Form) -> impl IntoResponse { // Reload users on login attempt to get fresh data - let user_manager = state.get_users().await; + let user_manager = match UserManager::load_async().await { + Ok(m) => m, + Err(e) => { + error!("Failed to load user manager: {}", e); + return (StatusCode::INTERNAL_SERVER_ERROR, "Internal Server Error").into_response(); + } + }; if let Some(user) = user_manager.verify_async(&payload.username, &payload.password).await { let session_user = SessionUser { @@ -328,7 +226,7 @@ async fn dashboard(State(state): State, session: Session) -> impl I let is_admin = matches!(user.role, Role::Admin); let services = services::get_all_services(); - let config = state.get_config().await; + let config = Config::load_async().await.unwrap_or_default(); // System Stats let mut sys = state.system.lock().unwrap(); @@ -495,7 +393,7 @@ async fn dashboard(State(state): State, session: Session) -> impl I } // User Management Page -async fn users_page(State(state): State, session: Session) -> impl IntoResponse { +async fn users_page(session: Session) -> impl IntoResponse { let user: SessionUser = match session.get(SESSION_KEY).await { Ok(Some(u)) => u, _ => return Redirect::to("/login").into_response(), @@ -505,7 +403,7 @@ async fn users_page(State(state): State, session: Session) -> impl return Redirect::to("/").into_response(); } - let user_manager = state.get_users().await; + let user_manager = UserManager::load_async().await.unwrap_or_default(); let mut html = String::with_capacity(4096); write_html_head(&mut html, "User Management - Server Manager"); @@ -579,8 +477,6 @@ async fn users_page(State(state): State, session: Session) -> impl } } - // Don't allow deleting self or last admin logic is handled in delete handler/manager - // But let's show delete button generally let _ = write!(html, r#"
@@ -605,7 +501,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(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 +522,38 @@ 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) - }); - - 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(); + // Load fresh manager + // We use load_async to get the current state, but we need to modify it. + // add_user modifies 'self'. + // load_async returns a clone. If we modify the clone and save, it updates the file. + // This is valid. + let username_for_log = payload.username.clone(); + if let Ok(mut manager) = UserManager::load_async().await { + // We need to run add_user in blocking task because it does hashing/fs + let res = tokio::task::spawn_blocking(move || { + manager.add_user(&payload.username, &payload.password, role_enum, quota_val) + }).await; + + match res { + Ok(Ok(_)) => { + info!("User {} added via Web UI by {}", username_for_log, session_user.username); + UserManager::invalidate_cache_async().await; + } + Ok(Err(e)) => { + error!("Failed to add user: {}", e); + } + Err(e) => { + error!("Task join error: {}", e); + } } + } else { + error!("Failed to load user manager for adding user"); } 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(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,21 +563,23 @@ 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 username_for_log = username.clone(); + if let Ok(mut manager) = UserManager::load_async().await { + let res = tokio::task::spawn_blocking(move || { + manager.delete_user(&username) + }).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(); + match res { + Ok(Ok(_)) => { + info!("User {} deleted via Web UI by {}", username_for_log, session_user.username); + UserManager::invalidate_cache_async().await; + } + Ok(Err(e)) => { + error!("Failed to delete user: {}", e); + } + Err(e) => { + error!("Task join error: {}", e); + } } }