Skip to main content

max / makenotwork

6.3 KB · 177 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 currency;
17 mod format;
18 mod ota;
19 mod rate_limit;
20 mod rpm;
21 mod ssh;
22 mod staging;
23 mod tls;
24 mod tui;
25
26 use std::sync::Arc;
27
28 use russh::MethodKind;
29 use russh::keys::{self, Algorithm, PrivateKey, ssh_key};
30 use russh::server::Server as _;
31 use tokio::signal;
32 use tracing_subscriber::EnvFilter;
33
34 #[tokio::main]
35 async fn main() -> anyhow::Result<()> {
36 let argv: Vec<String> = std::env::args().collect();
37
38 // `--version`, answered before anything else and before any config is read.
39 //
40 // Not decoration. This binary was placed on astra by hand and sat four
41 // months stale (an April build against a repo that had moved on), and the
42 // reason nobody noticed is that there was no way to ask a running one what
43 // it was: the only handle was the mtime of the file. A service that cannot
44 // say its own version cannot be audited, and Bento's release recipes assert
45 // `<binary> --version | grep -qw <version>` to prove the checkout and the
46 // release agree. Infra `e6acf532`.
47 //
48 // Hand-rolled rather than reached for clap: this crate takes no argument
49 // parser at all (`ota` routes on `argv[1]` a few lines down), and a
50 // dependency for one string would be the wrong trade.
51 if matches!(
52 argv.get(1).map(String::as_str),
53 Some("--version" | "-V" | "version")
54 ) {
55 println!("mnw-cli {}", env!("CARGO_PKG_VERSION"));
56 return Ok(());
57 }
58
59 // One-shot operator subcommand: `mnw-cli ota publish ...`. Routed before the
60 // SSH daemon boots so the same binary doubles as the OTA publisher.
61 if argv.get(1).map(String::as_str) == Some("ota") {
62 return ota::run(&argv[2..]).await;
63 }
64 // Same shape as `ota` above: an operator one-shot, routed off argv before
65 // the SSH daemon starts.
66 if argv.get(1).map(String::as_str) == Some("rpm") {
67 return rpm::run(&argv[2..]).await;
68 }
69
70 tracing_subscriber::fmt()
71 .with_env_filter(EnvFilter::from_default_env().add_directive("mnw_cli=info".parse()?))
72 .init();
73
74 let config = config::Config::from_env()?;
75
76 // Ensure staging base directory exists
77 std::fs::create_dir_all(&config.staging_dir)?;
78 tracing::info!(staging_dir = %config.staging_dir.display(), "staging directory ready");
79
80 // Spawn hourly cleanup task for stale staging files (24h TTL)
81 let cleanup_dir = config.staging_dir.clone();
82 tokio::spawn(async move {
83 let ttl = std::time::Duration::from_hours(24);
84 let mut interval = tokio::time::interval(std::time::Duration::from_hours(1));
85 loop {
86 interval.tick().await;
87 staging::cleanup_stale(&cleanup_dir, ttl).await;
88 }
89 });
90
91 // Load or generate host key
92 let host_key = load_or_generate_host_key(&config.host_key_path)?;
93
94 tracing::info!(
95 port = config.port,
96 api_url = %config.api_url,
97 host_key = %config.host_key_path.display(),
98 "starting MNW CLI SSH server"
99 );
100
101 let mut methods = russh::MethodSet::empty();
102 methods.push(MethodKind::PublicKey);
103
104 let ssh_config = russh::server::Config {
105 methods,
106 keys: vec![host_key],
107 auth_rejection_time: std::time::Duration::from_secs(1),
108 auth_rejection_time_initial: Some(std::time::Duration::from_millis(0)),
109 ..Default::default()
110 };
111
112 let staging_dir = Arc::new(config.staging_dir);
113 let api_client = api::MnwApiClient::new(config.api_url, config.service_token);
114 let rate_limiter = Arc::new(rate_limit::AuthRateLimiter::new());
115 let mut server = ssh::MnwServer::new(api_client, staging_dir, config.git_user, rate_limiter);
116
117 let addr = format!("0.0.0.0:{}", config.port);
118 tracing::info!(%addr, "listening for SSH connections");
119
120 // Run SSH server with graceful shutdown on SIGTERM/SIGINT
121 tokio::select! {
122 result = server.run_on_address(Arc::new(ssh_config), addr) => {
123 result?;
124 }
125 () = shutdown_signal() => {
126 tracing::info!("shutdown signal received, stopping");
127 }
128 }
129
130 tracing::info!("MNW CLI server stopped");
131 Ok(())
132 }
133
134 async fn shutdown_signal() {
135 let ctrl_c = signal::ctrl_c();
136 #[cfg(unix)]
137 let mut sigterm = signal::unix::signal(signal::unix::SignalKind::terminate())
138 .expect("failed to register SIGTERM handler");
139 #[cfg(unix)]
140 tokio::select! {
141 _ = ctrl_c => {}
142 _ = sigterm.recv() => {}
143 }
144 #[cfg(not(unix))]
145 ctrl_c.await.ok();
146 }
147
148 /// Load an ed25519 host key from disk, or generate and save one if it doesn't exist.
149 fn load_or_generate_host_key(path: &std::path::Path) -> anyhow::Result<PrivateKey> {
150 if path.exists() {
151 tracing::info!(path = %path.display(), "loading host key");
152 let key = keys::load_secret_key(path, None)?;
153 Ok(key)
154 } else {
155 tracing::info!(path = %path.display(), "generating new ed25519 host key");
156 // `rand::rng()` is a CSPRNG seeded from the OS. It used to be routed
157 // through russh's re-exported rand_core to dodge a version conflict with
158 // the standalone rand crate; russh 0.62 dropped its rand_core RC pin, so
159 // both now agree on rand 0.10 and the workaround is gone. This is the
160 // same call russh itself uses to generate keys.
161 let key = ssh_key::private::PrivateKey::random(&mut rand::rng(), Algorithm::Ed25519)?;
162 // Save to disk in OpenSSH format
163 let pem = key.to_openssh(ssh_key::LineEnding::LF)?;
164 if let Some(parent) = path.parent() {
165 std::fs::create_dir_all(parent)?;
166 }
167 std::fs::write(path, pem.as_bytes())?;
168 #[cfg(unix)]
169 {
170 use std::os::unix::fs::PermissionsExt;
171 std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600))?;
172 }
173 tracing::info!(path = %path.display(), "host key saved");
174 Ok(key)
175 }
176 }
177