Skip to main content

max / makenotwork

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