//! MNW CLI — SSH-based TUI for the Makenot.work creator platform. //! //! Runs a russh SSH server that authenticates creators by their registered //! SSH public keys (via the MNW internal API) and presents a ratatui TUI //! for managing projects, items, uploads, and analytics. mod api; mod commands; mod config; mod format; mod ssh; mod staging; mod tui; use std::sync::Arc; use russh::keys::{self, Algorithm, PrivateKey, ssh_key}; use russh::server::Server as _; use russh::MethodKind; use tokio::signal; use tracing_subscriber::EnvFilter; #[tokio::main] async fn main() -> anyhow::Result<()> { tracing_subscriber::fmt() .with_env_filter(EnvFilter::from_default_env().add_directive("mnw_cli=info".parse()?)) .init(); let config = config::Config::from_env()?; // Ensure staging base directory exists std::fs::create_dir_all(&config.staging_dir)?; tracing::info!(staging_dir = %config.staging_dir.display(), "staging directory ready"); // Spawn hourly cleanup task for stale staging files (24h TTL) let cleanup_dir = config.staging_dir.clone(); tokio::spawn(async move { let ttl = std::time::Duration::from_secs(24 * 60 * 60); let mut interval = tokio::time::interval(std::time::Duration::from_secs(60 * 60)); loop { interval.tick().await; staging::cleanup_stale(&cleanup_dir, ttl).await; } }); // Load or generate host key let host_key = load_or_generate_host_key(&config.host_key_path)?; tracing::info!( port = config.port, api_url = %config.api_url, host_key = %config.host_key_path.display(), "starting MNW CLI SSH server" ); let mut methods = russh::MethodSet::empty(); methods.push(MethodKind::PublicKey); let ssh_config = russh::server::Config { methods, keys: vec![host_key], auth_rejection_time: std::time::Duration::from_secs(1), auth_rejection_time_initial: Some(std::time::Duration::from_millis(0)), ..Default::default() }; let staging_dir = Arc::new(config.staging_dir); let api_client = api::MnwApiClient::new(config.api_url, config.service_token); let mut server = ssh::MnwServer::new(api_client, staging_dir, config.git_user); let addr = format!("0.0.0.0:{}", config.port); tracing::info!(%addr, "listening for SSH connections"); // Run SSH server with graceful shutdown on SIGTERM/SIGINT tokio::select! { result = server.run_on_address(Arc::new(ssh_config), addr) => { result?; } _ = shutdown_signal() => { tracing::info!("shutdown signal received, stopping"); } } tracing::info!("MNW CLI server stopped"); Ok(()) } async fn shutdown_signal() { let ctrl_c = signal::ctrl_c(); #[cfg(unix)] let mut sigterm = signal::unix::signal(signal::unix::SignalKind::terminate()) .expect("failed to register SIGTERM handler"); #[cfg(unix)] tokio::select! { _ = ctrl_c => {} _ = sigterm.recv() => {} } #[cfg(not(unix))] ctrl_c.await.ok(); } /// Load an ed25519 host key from disk, or generate and save one if it doesn't exist. fn load_or_generate_host_key(path: &std::path::Path) -> anyhow::Result { if path.exists() { tracing::info!(path = %path.display(), "loading host key"); let key = keys::load_secret_key(path, None)?; Ok(key) } else { tracing::info!(path = %path.display(), "generating new ed25519 host key"); // Use the rand_core OsRng re-exported through russh's ssh_key dependency // to avoid version conflicts with the standalone rand crate. let key = ssh_key::private::PrivateKey::random( &mut keys::signature::rand_core::OsRng, Algorithm::Ed25519, )?; // Save to disk in OpenSSH format let pem = key.to_openssh(ssh_key::LineEnding::LF)?; if let Some(parent) = path.parent() { std::fs::create_dir_all(parent)?; } std::fs::write(path, pem.as_bytes())?; #[cfg(unix)] { use std::os::unix::fs::PermissionsExt; std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600))?; } tracing::info!(path = %path.display(), "host key saved"); Ok(key) } }