use clap::{Parser, Subcommand}; use myfsio_server::config::ServerConfig; use myfsio_server::state::AppState; #[derive(Parser)] #[command( name = "myfsio", version, about = "MyFSIO S3-compatible storage engine" )] struct Cli { #[arg(long, help = "Validate configuration and exit")] check_config: bool, #[arg(long, help = "Show configuration summary and exit")] show_config: bool, #[arg(long, help = "Reset admin credentials and exit")] reset_cred: bool, #[command(subcommand)] command: Option, } #[derive(Subcommand)] enum Command { Serve, Version, } #[tokio::main] async fn main() { load_env_files(); init_tracing(); let cli = Cli::parse(); let config = ServerConfig::from_env(); if !config .ratelimit_storage_uri .eq_ignore_ascii_case("memory://") { tracing::warn!( "RATE_LIMIT_STORAGE_URI={} is not supported yet; using in-memory rate limits", config.ratelimit_storage_uri ); } if cli.reset_cred { reset_admin_credentials(&config); return; } if cli.check_config || cli.show_config { print_config_summary(&config); if cli.check_config { let issues = validate_config(&config); for issue in &issues { println!("{issue}"); } if issues.iter().any(|issue| issue.starts_with("CRITICAL:")) { std::process::exit(1); } } return; } match cli.command.unwrap_or(Command::Serve) { Command::Version => { println!("myfsio {}", env!("CARGO_PKG_VERSION")); return; } Command::Serve => {} } ensure_iam_bootstrap(&config); let bind_addr = config.bind_addr; let ui_bind_addr = config.ui_bind_addr; tracing::info!("MyFSIO Rust Engine starting — API on {}", bind_addr); if config.ui_enabled { tracing::info!("UI will bind on {}", ui_bind_addr); } tracing::info!("Storage root: {}", config.storage_root.display()); tracing::info!("Region: {}", config.region); tracing::info!( "Encryption: {}, KMS: {}, GC: {}, Lifecycle: {}, Integrity: {}, Metrics History: {}, Operation Metrics: {}, UI: {}", config.encryption_enabled, config.kms_enabled, config.gc_enabled, config.lifecycle_enabled, config.integrity_enabled, config.metrics_history_enabled, config.metrics_enabled, config.ui_enabled ); let state = if config.encryption_enabled || config.kms_enabled { AppState::new_with_encryption(config.clone()).await } else { AppState::new(config.clone()) }; let mut bg_handles: Vec> = Vec::new(); if let Some(ref gc) = state.gc { bg_handles.push(gc.clone().start_background()); tracing::info!("GC background service started"); } if let Some(ref integrity) = state.integrity { bg_handles.push(integrity.clone().start_background()); tracing::info!("Integrity checker background service started"); } if let Some(ref metrics) = state.metrics { bg_handles.push(metrics.clone().start_background()); tracing::info!("Metrics collector background service started"); } if let Some(ref system_metrics) = state.system_metrics { bg_handles.push(system_metrics.clone().start_background()); tracing::info!("System metrics history collector started"); } if config.lifecycle_enabled { let lifecycle = std::sync::Arc::new(myfsio_server::services::lifecycle::LifecycleService::new( state.storage.clone(), config.storage_root.clone(), myfsio_server::services::lifecycle::LifecycleConfig { interval_seconds: 3600, max_history_per_bucket: config.lifecycle_max_history_per_bucket, }, )); bg_handles.push(lifecycle.start_background()); tracing::info!("Lifecycle manager background service started"); } if let Some(ref site_sync) = state.site_sync { let worker = site_sync.clone(); bg_handles.push(tokio::spawn(async move { worker.run().await; })); tracing::info!("Site sync worker started"); } let ui_enabled = config.ui_enabled; let api_app = myfsio_server::create_router(state.clone()); let ui_app = if ui_enabled { Some(myfsio_server::create_ui_router(state.clone())) } else { None }; let api_listener = match tokio::net::TcpListener::bind(bind_addr).await { Ok(listener) => listener, Err(err) => { if err.kind() == std::io::ErrorKind::AddrInUse { tracing::error!("API port already in use: {}", bind_addr); } else { tracing::error!("Failed to bind API {}: {}", bind_addr, err); } for handle in bg_handles { handle.abort(); } std::process::exit(1); } }; tracing::info!("API listening on {}", bind_addr); let ui_listener = if let Some(ref app) = ui_app { let _ = app; match tokio::net::TcpListener::bind(ui_bind_addr).await { Ok(listener) => { tracing::info!("UI listening on {}", ui_bind_addr); Some(listener) } Err(err) => { if err.kind() == std::io::ErrorKind::AddrInUse { tracing::error!("UI port already in use: {}", ui_bind_addr); } else { tracing::error!("Failed to bind UI {}: {}", ui_bind_addr, err); } for handle in bg_handles { handle.abort(); } std::process::exit(1); } } } else { None }; let shutdown = shutdown_signal_shared(); let api_shutdown = shutdown.clone(); let api_listener = axum::serve::ListenerExt::tap_io(api_listener, |stream| { if let Err(err) = stream.set_nodelay(true) { tracing::trace!("failed to set TCP_NODELAY on api socket: {}", err); } }); let api_task = tokio::spawn(async move { axum::serve( api_listener, api_app.into_make_service_with_connect_info::(), ) .with_graceful_shutdown(async move { api_shutdown.notified().await; }) .await }); let ui_task = if let (Some(listener), Some(app)) = (ui_listener, ui_app) { let ui_shutdown = shutdown.clone(); let listener = axum::serve::ListenerExt::tap_io(listener, |stream| { if let Err(err) = stream.set_nodelay(true) { tracing::trace!("failed to set TCP_NODELAY on ui socket: {}", err); } }); Some(tokio::spawn(async move { axum::serve(listener, app) .with_graceful_shutdown(async move { ui_shutdown.notified().await; }) .await })) } else { None }; tokio::signal::ctrl_c() .await .expect("Failed to listen for Ctrl+C"); tracing::info!("Shutdown signal received"); shutdown.notify_waiters(); if let Err(err) = api_task.await.unwrap_or(Ok(())) { tracing::error!("API server exited with error: {}", err); } if let Some(task) = ui_task { if let Err(err) = task.await.unwrap_or(Ok(())) { tracing::error!("UI server exited with error: {}", err); } } for handle in bg_handles { handle.abort(); } } fn print_config_summary(config: &ServerConfig) { println!("MyFSIO Rust Configuration"); println!("Version: {}", env!("CARGO_PKG_VERSION")); println!("API bind: {}", config.bind_addr); println!("UI bind: {}", config.ui_bind_addr); println!("UI enabled: {}", config.ui_enabled); println!("Storage root: {}", config.storage_root.display()); println!("IAM config: {}", config.iam_config_path.display()); println!("Region: {}", config.region); println!("Encryption enabled: {}", config.encryption_enabled); println!( "Encryption chunk size: {} bytes", config.encryption_chunk_size_bytes ); println!("KMS enabled: {}", config.kms_enabled); println!( "KMS data key bounds: {}-{} bytes", config.kms_generate_data_key_min_bytes, config.kms_generate_data_key_max_bytes ); println!("GC enabled: {}", config.gc_enabled); println!( "GC interval: {} hours, dry run: {}", config.gc_interval_hours, config.gc_dry_run ); println!("Integrity enabled: {}", config.integrity_enabled); println!("Lifecycle enabled: {}", config.lifecycle_enabled); println!( "Lifecycle history limit: {}", config.lifecycle_max_history_per_bucket ); println!( "Website hosting enabled: {}", config.website_hosting_enabled ); println!("Site sync enabled: {}", config.site_sync_enabled); println!("API base URL: {}", config.api_base_url); println!( "Object key max: {} bytes, tag limit: {}", config.object_key_max_length_bytes, config.object_tag_limit ); println!( "Rate limits: default {} per {}s, admin {} per {}s", config.ratelimit_default.max_requests, config.ratelimit_default.window_seconds, config.ratelimit_admin.max_requests, config.ratelimit_admin.window_seconds ); println!( "Metrics history enabled: {}", config.metrics_history_enabled ); println!("Operation metrics enabled: {}", config.metrics_enabled); } fn validate_config(config: &ServerConfig) -> Vec { let mut issues = Vec::new(); if config.ui_enabled && config.bind_addr == config.ui_bind_addr { issues.push( "CRITICAL: API and UI bind addresses cannot be identical when UI is enabled." .to_string(), ); } if config.presigned_url_min_expiry > config.presigned_url_max_expiry { issues.push("CRITICAL: PRESIGNED_URL_MIN_EXPIRY_SECONDS cannot exceed PRESIGNED_URL_MAX_EXPIRY_SECONDS.".to_string()); } if config.encryption_chunk_size_bytes == 0 { issues.push("CRITICAL: ENCRYPTION_CHUNK_SIZE_BYTES must be greater than zero.".to_string()); } if config.kms_generate_data_key_min_bytes == 0 { issues.push( "CRITICAL: KMS_GENERATE_DATA_KEY_MIN_BYTES must be greater than zero.".to_string(), ); } if config.kms_generate_data_key_min_bytes > config.kms_generate_data_key_max_bytes { issues.push("CRITICAL: KMS_GENERATE_DATA_KEY_MIN_BYTES cannot exceed KMS_GENERATE_DATA_KEY_MAX_BYTES.".to_string()); } if config.gc_interval_hours <= 0.0 { issues.push("CRITICAL: GC_INTERVAL_HOURS must be greater than zero.".to_string()); } if config.bucket_config_cache_ttl_seconds < 0.0 { issues.push("CRITICAL: BUCKET_CONFIG_CACHE_TTL_SECONDS cannot be negative.".to_string()); } if !config .ratelimit_storage_uri .eq_ignore_ascii_case("memory://") { issues.push(format!( "WARNING: RATE_LIMIT_STORAGE_URI={} is not supported yet; using in-memory limits.", config.ratelimit_storage_uri )); } if let Err(err) = std::fs::create_dir_all(&config.storage_root) { issues.push(format!( "CRITICAL: Cannot create storage root {}: {}", config.storage_root.display(), err )); } if let Some(parent) = config.iam_config_path.parent() { if let Err(err) = std::fs::create_dir_all(parent) { issues.push(format!( "CRITICAL: Cannot create IAM config directory {}: {}", parent.display(), err )); } } if config.encryption_enabled && config.secret_key.is_none() { issues.push( "WARNING: ENCRYPTION_ENABLED=true but SECRET_KEY is not configured; secure-at-rest config encryption is unavailable.".to_string(), ); } if config.site_sync_enabled && !config.website_hosting_enabled { issues.push( "INFO: SITE_SYNC_ENABLED=true without WEBSITE_HOSTING_ENABLED; this is valid but unrelated.".to_string(), ); } issues } fn init_tracing() { use tracing_subscriber::EnvFilter; let filter = EnvFilter::try_from_env("RUST_LOG") .or_else(|_| { EnvFilter::try_new(std::env::var("LOG_LEVEL").unwrap_or_else(|_| "INFO".to_string())) }) .unwrap_or_else(|_| EnvFilter::new("INFO")); tracing_subscriber::fmt().with_env_filter(filter).init(); } fn shutdown_signal_shared() -> std::sync::Arc { std::sync::Arc::new(tokio::sync::Notify::new()) } fn load_env_files() { let cwd = std::env::current_dir().ok(); let mut candidates: Vec = Vec::new(); candidates.push(std::path::PathBuf::from("/opt/myfsio/myfsio.env")); if let Some(ref dir) = cwd { candidates.push(dir.join(".env")); candidates.push(dir.join("myfsio.env")); for ancestor in dir.ancestors().skip(1).take(4) { candidates.push(ancestor.join(".env")); candidates.push(ancestor.join("myfsio.env")); } } let mut seen = std::collections::HashSet::new(); for path in candidates { if !seen.insert(path.clone()) { continue; } if path.is_file() { match dotenvy::from_path_override(&path) { Ok(()) => eprintln!("Loaded env file: {}", path.display()), Err(e) => eprintln!("Failed to load env file {}: {}", path.display(), e), } } } } fn ensure_iam_bootstrap(config: &ServerConfig) { let iam_path = &config.iam_config_path; if iam_path.exists() { return; } let access_key = std::env::var("ADMIN_ACCESS_KEY") .ok() .map(|s| s.trim().to_string()) .filter(|s| !s.is_empty()) .unwrap_or_else(|| format!("AK{}", uuid::Uuid::new_v4().simple())); let secret_key = std::env::var("ADMIN_SECRET_KEY") .ok() .map(|s| s.trim().to_string()) .filter(|s| !s.is_empty()) .unwrap_or_else(|| format!("SK{}", uuid::Uuid::new_v4().simple())); let user_id = format!("u-{}", &uuid::Uuid::new_v4().simple().to_string()[..16]); let created_at = chrono::Utc::now().to_rfc3339(); let body = serde_json::json!({ "version": 2, "users": [{ "user_id": user_id, "display_name": "Local Admin", "enabled": true, "access_keys": [{ "access_key": access_key, "secret_key": secret_key, "status": "active", "created_at": created_at, }], "policies": [{ "bucket": "*", "actions": ["*"], "prefix": "*", }] }] }); let json = match serde_json::to_string_pretty(&body) { Ok(s) => s, Err(e) => { tracing::error!("Failed to serialize IAM bootstrap config: {}", e); return; } }; if let Some(parent) = iam_path.parent() { if let Err(e) = std::fs::create_dir_all(parent) { tracing::error!( "Failed to create IAM config dir {}: {}", parent.display(), e ); return; } } if let Err(e) = std::fs::write(iam_path, json) { tracing::error!( "Failed to write IAM bootstrap config {}: {}", iam_path.display(), e ); return; } tracing::info!("============================================================"); tracing::info!("MYFSIO - ADMIN CREDENTIALS INITIALIZED"); tracing::info!("============================================================"); tracing::info!("Access Key: {}", access_key); tracing::info!("Secret Key: {}", secret_key); tracing::info!("Saved to: {}", iam_path.display()); tracing::info!("============================================================"); } fn reset_admin_credentials(config: &ServerConfig) { if let Some(parent) = config.iam_config_path.parent() { if let Err(err) = std::fs::create_dir_all(parent) { eprintln!( "Failed to create IAM config directory {}: {}", parent.display(), err ); std::process::exit(1); } } if config.iam_config_path.exists() { let backup = config .iam_config_path .with_extension(format!("bak-{}", chrono::Utc::now().timestamp())); if let Err(err) = std::fs::rename(&config.iam_config_path, &backup) { eprintln!( "Failed to back up existing IAM config {}: {}", config.iam_config_path.display(), err ); std::process::exit(1); } println!("Backed up existing IAM config to {}", backup.display()); prune_iam_backups(&config.iam_config_path, 5); } ensure_iam_bootstrap(config); println!("Admin credentials reset."); } fn prune_iam_backups(iam_path: &std::path::Path, keep: usize) { let parent = match iam_path.parent() { Some(p) => p, None => return, }; let stem = match iam_path.file_stem().and_then(|s| s.to_str()) { Some(s) => s, None => return, }; let prefix = format!("{}.bak-", stem); let entries = match std::fs::read_dir(parent) { Ok(entries) => entries, Err(_) => return, }; let mut backups: Vec<(i64, std::path::PathBuf)> = entries .filter_map(|e| e.ok()) .filter_map(|e| { let path = e.path(); let name = path.file_name()?.to_str()?; let rest = name.strip_prefix(&prefix)?; let ts: i64 = rest.parse().ok()?; Some((ts, path)) }) .collect(); backups.sort_by(|a, b| b.0.cmp(&a.0)); for (_, path) in backups.into_iter().skip(keep) { if let Err(err) = std::fs::remove_file(&path) { eprintln!( "Failed to remove old IAM backup {}: {}", path.display(), err ); } else { println!("Pruned old IAM backup {}", path.display()); } } }