Skip to main content

max / makenotwork

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