Skip to main content

max / makenotwork

5.0 KB · 147 lines History Blame Raw
1 //! MNW CLI — SSH-based TUI for the Makenot.work creator platform.
2 //!
3 //! Runs a russh SSH server that authenticates creators by their registered
4 //! SSH public keys (via the MNW internal API) and presents a ratatui TUI
5 //! for managing projects, items, uploads, and analytics.
6 //!
7 //! # Design
8 //!
9 //! The full module map is in `docs/architecture.md`; the ecosystem view and
10 //! the OTA-publish design live in the maintainer wiki.
11 //! <!-- wiki: mnw-cli-overview -->
12
13 mod api;
14 mod commands;
15 mod config;
16 mod format;
17 mod ota;
18 mod rate_limit;
19 mod ssh;
20 mod staging;
21 mod tui;
22
23 use std::sync::Arc;
24
25 use russh::MethodKind;
26 use russh::keys::{self, Algorithm, PrivateKey, ssh_key};
27 use russh::server::Server as _;
28 use tokio::signal;
29 use tracing_subscriber::EnvFilter;
30
31 #[tokio::main]
32 async fn main() -> anyhow::Result<()> {
33 // One-shot operator subcommand: `mnw-cli ota publish ...`. Routed before the
34 // SSH daemon boots so the same binary doubles as the OTA publisher.
35 let argv: Vec<String> = std::env::args().collect();
36 if argv.get(1).map(String::as_str) == Some("ota") {
37 return ota::run(&argv[2..]).await;
38 }
39
40 tracing_subscriber::fmt()
41 .with_env_filter(EnvFilter::from_default_env().add_directive("mnw_cli=info".parse()?))
42 .init();
43
44 let config = config::Config::from_env()?;
45
46 // Ensure staging base directory exists
47 std::fs::create_dir_all(&config.staging_dir)?;
48 tracing::info!(staging_dir = %config.staging_dir.display(), "staging directory ready");
49
50 // Spawn hourly cleanup task for stale staging files (24h TTL)
51 let cleanup_dir = config.staging_dir.clone();
52 tokio::spawn(async move {
53 let ttl = std::time::Duration::from_hours(24);
54 let mut interval = tokio::time::interval(std::time::Duration::from_hours(1));
55 loop {
56 interval.tick().await;
57 staging::cleanup_stale(&cleanup_dir, ttl).await;
58 }
59 });
60
61 // Load or generate host key
62 let host_key = load_or_generate_host_key(&config.host_key_path)?;
63
64 tracing::info!(
65 port = config.port,
66 api_url = %config.api_url,
67 host_key = %config.host_key_path.display(),
68 "starting MNW CLI SSH server"
69 );
70
71 let mut methods = russh::MethodSet::empty();
72 methods.push(MethodKind::PublicKey);
73
74 let ssh_config = russh::server::Config {
75 methods,
76 keys: vec![host_key],
77 auth_rejection_time: std::time::Duration::from_secs(1),
78 auth_rejection_time_initial: Some(std::time::Duration::from_millis(0)),
79 ..Default::default()
80 };
81
82 let staging_dir = Arc::new(config.staging_dir);
83 let api_client = api::MnwApiClient::new(config.api_url, config.service_token);
84 let rate_limiter = Arc::new(rate_limit::AuthRateLimiter::new());
85 let mut server = ssh::MnwServer::new(api_client, staging_dir, config.git_user, rate_limiter);
86
87 let addr = format!("0.0.0.0:{}", config.port);
88 tracing::info!(%addr, "listening for SSH connections");
89
90 // Run SSH server with graceful shutdown on SIGTERM/SIGINT
91 tokio::select! {
92 result = server.run_on_address(Arc::new(ssh_config), addr) => {
93 result?;
94 }
95 () = shutdown_signal() => {
96 tracing::info!("shutdown signal received, stopping");
97 }
98 }
99
100 tracing::info!("MNW CLI server stopped");
101 Ok(())
102 }
103
104 async fn shutdown_signal() {
105 let ctrl_c = signal::ctrl_c();
106 #[cfg(unix)]
107 let mut sigterm = signal::unix::signal(signal::unix::SignalKind::terminate())
108 .expect("failed to register SIGTERM handler");
109 #[cfg(unix)]
110 tokio::select! {
111 _ = ctrl_c => {}
112 _ = sigterm.recv() => {}
113 }
114 #[cfg(not(unix))]
115 ctrl_c.await.ok();
116 }
117
118 /// Load an ed25519 host key from disk, or generate and save one if it doesn't exist.
119 fn load_or_generate_host_key(path: &std::path::Path) -> anyhow::Result<PrivateKey> {
120 if path.exists() {
121 tracing::info!(path = %path.display(), "loading host key");
122 let key = keys::load_secret_key(path, None)?;
123 Ok(key)
124 } else {
125 tracing::info!(path = %path.display(), "generating new ed25519 host key");
126 // `rand::rng()` is a CSPRNG seeded from the OS. It used to be routed
127 // through russh's re-exported rand_core to dodge a version conflict with
128 // the standalone rand crate; russh 0.62 dropped its rand_core RC pin, so
129 // both now agree on rand 0.10 and the workaround is gone. This is the
130 // same call russh itself uses to generate keys.
131 let key = ssh_key::private::PrivateKey::random(&mut rand::rng(), Algorithm::Ed25519)?;
132 // Save to disk in OpenSSH format
133 let pem = key.to_openssh(ssh_key::LineEnding::LF)?;
134 if let Some(parent) = path.parent() {
135 std::fs::create_dir_all(parent)?;
136 }
137 std::fs::write(path, pem.as_bytes())?;
138 #[cfg(unix)]
139 {
140 use std::os::unix::fs::PermissionsExt;
141 std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600))?;
142 }
143 tracing::info!(path = %path.display(), "host key saved");
144 Ok(key)
145 }
146 }
147