//! 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. //! //! # Design //! //! The full module map is in `docs/architecture.md`; the ecosystem view and //! the OTA-publish design live in the maintainer wiki. //! mod api; mod commands; mod config; mod currency; mod format; mod ota; mod rate_limit; mod rpm; mod ssh; mod staging; mod tls; mod tui; use std::sync::Arc; use russh::MethodKind; use russh::keys::{self, Algorithm, PrivateKey, ssh_key}; use russh::server::Server as _; use tokio::signal; use tracing_subscriber::EnvFilter; #[tokio::main] async fn main() -> anyhow::Result<()> { let argv: Vec = std::env::args().collect(); // `--version`, answered before anything else and before any config is read. // // Not decoration. This binary was placed on astra by hand and sat four // months stale (an April build against a repo that had moved on), and the // reason nobody noticed is that there was no way to ask a running one what // it was: the only handle was the mtime of the file. A service that cannot // say its own version cannot be audited, and Bento's release recipes assert // ` --version | grep -qw ` to prove the checkout and the // release agree. Infra `e6acf532`. // // Hand-rolled rather than reached for clap: this crate takes no argument // parser at all (`ota` routes on `argv[1]` a few lines down), and a // dependency for one string would be the wrong trade. if matches!( argv.get(1).map(String::as_str), Some("--version" | "-V" | "version") ) { println!("mnw-cli {}", env!("CARGO_PKG_VERSION")); return Ok(()); } // One-shot operator subcommand: `mnw-cli ota publish ...`. Routed before the // SSH daemon boots so the same binary doubles as the OTA publisher. if argv.get(1).map(String::as_str) == Some("ota") { return ota::run(&argv[2..]).await; } // Same shape as `ota` above: an operator one-shot, routed off argv before // the SSH daemon starts. if argv.get(1).map(String::as_str) == Some("rpm") { return rpm::run(&argv[2..]).await; } 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_hours(24); let mut interval = tokio::time::interval(std::time::Duration::from_hours(1)); 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 rate_limiter = Arc::new(rate_limit::AuthRateLimiter::new()); let mut server = ssh::MnwServer::new(api_client, staging_dir, config.git_user, rate_limiter); 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"); // `rand::rng()` is a CSPRNG seeded from the OS, and the same call // russh itself uses to generate keys. let key = ssh_key::private::PrivateKey::random(&mut rand::rng(), 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) } }