Skip to main content

max / makenotwork

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