ssh.rs 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491
  1. use anyhow::{Context, Result};
  2. use base64::Engine;
  3. use base64::engine::general_purpose::STANDARD;
  4. use russh::client;
  5. use russh::keys::{PrivateKeyWithHashAlg, load_secret_key as russh_load_secret_key};
  6. use russh::{ChannelMsg, Error as SshError};
  7. use sha2::{Digest, Sha256};
  8. use std::path::Path;
  9. use std::sync::Arc;
  10. use tokio::time::{Duration, sleep};
  11. use tracing::warn;
  12. use crate::config::{Config, Node};
  13. const CONNECT_TIMEOUT: Duration = Duration::from_secs(5);
  14. /// Establishes an SSH session to a node using the config credentials, with a 5s connect
  15. /// and inactivity timeout. Use [`ssh_session_follow`] for long-lived streaming commands.
  16. pub async fn ssh_session(config: &Config, node: &Node) -> Result<client::Handle<VerifyingHandler>> {
  17. ssh_session_with_timeout(config, node, Some(CONNECT_TIMEOUT)).await
  18. }
  19. /// Like [`ssh_session`] but with no inactivity timeout — suitable for long-lived
  20. /// streaming commands such as `docker logs -f`.
  21. pub async fn ssh_session_follow(
  22. config: &Config,
  23. node: &Node,
  24. ) -> Result<client::Handle<VerifyingHandler>> {
  25. ssh_session_with_timeout(config, node, None).await
  26. }
  27. async fn ssh_session_with_timeout(
  28. config: &Config,
  29. node: &Node,
  30. inactivity_timeout: Option<Duration>,
  31. ) -> Result<client::Handle<VerifyingHandler>> {
  32. if config.ssh_user.is_empty() || config.ssh_key_path.as_os_str().is_empty() {
  33. anyhow::bail!(
  34. "ssh_user/ssh_key_path not set — no ./config.toml, and neither the global \
  35. config nor a cluster source provides them (see `sm121 config path`)"
  36. );
  37. }
  38. let key = cached_secret_key(&config.ssh_key_path)?;
  39. let ssh_config = russh::client::Config {
  40. inactivity_timeout,
  41. ..Default::default()
  42. };
  43. // Refuse to connect without a pinned host key — a missing `host_key`
  44. // would otherwise skip verification entirely (MITM vector). Run
  45. // `sm121 init-host-keys` to discover and pin keys, or set `host_key`
  46. // manually in config.toml. (`fetch_host_key` / init-host-keys uses a
  47. // separate capturing handler and is intentionally exempt.)
  48. if node.host_key.is_none() {
  49. anyhow::bail!(
  50. "No host_key set for node '{}' ({}). Refusing to connect without \
  51. host-key verification. Run `sm121 init-host-keys --cluster <name>` \
  52. to discover and pin host keys, or set `host_key` manually in config.toml.",
  53. node.name,
  54. node.ssh_ip
  55. );
  56. }
  57. let expected_key = Arc::new(std::sync::Mutex::new(node.host_key.clone()));
  58. let handler = VerifyingHandler { expected_key };
  59. let mut session = tokio::time::timeout(
  60. CONNECT_TIMEOUT,
  61. russh::client::connect(Arc::new(ssh_config), (node.ssh_ip.as_str(), 22), handler),
  62. )
  63. .await
  64. .context(format!(
  65. "SSH connect to {}:{} timed out after {}s",
  66. node.ssh_ip,
  67. 22,
  68. CONNECT_TIMEOUT.as_secs()
  69. ))??;
  70. session
  71. .authenticate_publickey(&config.ssh_user, PrivateKeyWithHashAlg::new(key, None))
  72. .await?;
  73. Ok(session)
  74. }
  75. /// Runs a command on a node via SSH and returns stdout.
  76. ///
  77. /// The remote command's exit status is tracked: if it is non-zero,
  78. /// an Err is returned whose `to_string()` includes the captured output
  79. /// for diagnosis. Callers that don't care about exit status can ignore
  80. /// the Result and inspect stdout via the returned `String`.
  81. pub async fn run_ssh(
  82. config: &Config,
  83. node: &Node,
  84. command: String,
  85. dry_run: bool,
  86. ) -> Result<String> {
  87. if dry_run {
  88. println!(" ssh {}@{}: {}", config.ssh_user, node.ssh_ip, command);
  89. return Ok(String::new());
  90. }
  91. let session = ssh_session(config, node).await?;
  92. let mut channel = session.channel_open_session().await?;
  93. channel.exec(true, command.as_bytes()).await?;
  94. let mut output = String::new();
  95. let mut exit_status: Option<u32> = None;
  96. while let Some(msg) = channel.wait().await {
  97. match msg {
  98. ChannelMsg::Data { data } => {
  99. output.push_str(&String::from_utf8_lossy(&data));
  100. }
  101. ChannelMsg::ExtendedData { data, .. } => {
  102. output.push_str(&String::from_utf8_lossy(&data));
  103. }
  104. ChannelMsg::ExitStatus { exit_status: s } => {
  105. exit_status = Some(s);
  106. }
  107. _ => {}
  108. }
  109. }
  110. match exit_status {
  111. Some(0) | None => Ok(output),
  112. Some(code) => Err(anyhow::anyhow!(
  113. "ssh {}@{}: command exited with status {}\n command: {}\n output:\n{}",
  114. config.ssh_user,
  115. node.ssh_ip,
  116. code,
  117. command,
  118. output
  119. )),
  120. }
  121. }
  122. /// Runs a command via SSH with exponential backoff retry.
  123. pub async fn run_ssh_with_retry(
  124. config: &Config,
  125. node: &Node,
  126. command: String,
  127. dry_run: bool,
  128. max_retries: u32,
  129. ) -> Result<String> {
  130. let mut last_err = None;
  131. for attempt in 0..=max_retries {
  132. match run_ssh(config, node, command.clone(), dry_run).await {
  133. Ok(output) => return Ok(output),
  134. Err(e) => {
  135. warn!(
  136. "SSH attempt {}/{} to {} failed: {}",
  137. attempt + 1,
  138. max_retries + 1,
  139. node.name,
  140. e
  141. );
  142. last_err = Some(e);
  143. if attempt < max_retries {
  144. sleep(Duration::from_secs(2u64.pow(attempt))).await;
  145. }
  146. }
  147. }
  148. }
  149. Err(last_err.unwrap())
  150. }
  151. /// Runs a command via SSH and streams output to stdout (for follow mode).
  152. /// Uses a session with no inactivity timeout so long-lived streams are not killed.
  153. pub async fn run_ssh_follow(
  154. config: &Config,
  155. node: &Node,
  156. command: String,
  157. dry_run: bool,
  158. ) -> Result<()> {
  159. if dry_run {
  160. println!(" ssh {}@{}: {} -f", config.ssh_user, node.ssh_ip, command);
  161. return Ok(());
  162. }
  163. let session = ssh_session_follow(config, node).await?;
  164. let mut channel = session.channel_open_session().await?;
  165. channel.exec(true, command.as_bytes()).await?;
  166. while let Some(msg) = channel.wait().await {
  167. match msg {
  168. ChannelMsg::Data { data } | ChannelMsg::ExtendedData { data, .. } => {
  169. print!("{}", String::from_utf8_lossy(&data));
  170. }
  171. _ => {}
  172. }
  173. }
  174. Ok(())
  175. }
  176. /// Runs a command via SSH with no inactivity timeout — suitable for long,
  177. /// streaming commands like `docker save | ssh docker load`.
  178. pub async fn run_ssh_long_running(
  179. config: &Config,
  180. node: &Node,
  181. command: String,
  182. dry_run: bool,
  183. ) -> Result<String> {
  184. if dry_run {
  185. println!(
  186. " ssh {}@{}: {} (long-running)",
  187. config.ssh_user, node.ssh_ip, command
  188. );
  189. return Ok(String::new());
  190. }
  191. let session = ssh_session_follow(config, node).await?;
  192. let mut channel = session.channel_open_session().await?;
  193. channel.exec(true, command.as_bytes()).await?;
  194. let mut output = String::new();
  195. let mut exit_status: Option<u32> = None;
  196. while let Some(msg) = channel.wait().await {
  197. match msg {
  198. ChannelMsg::Data { data } => output.push_str(&String::from_utf8_lossy(&data)),
  199. ChannelMsg::ExtendedData { data, .. } => {
  200. output.push_str(&String::from_utf8_lossy(&data))
  201. }
  202. ChannelMsg::ExitStatus { exit_status: s } => exit_status = Some(s),
  203. _ => {}
  204. }
  205. }
  206. match exit_status {
  207. Some(0) | None => Ok(output),
  208. Some(code) => Err(anyhow::anyhow!(
  209. "ssh {}@{}: command exited with status {}\n command: {}",
  210. config.ssh_user,
  211. node.ssh_ip,
  212. code,
  213. command
  214. )),
  215. }
  216. }
  217. /// Fetches the SSH host key fingerprint from a node.
  218. pub async fn fetch_host_key(config: &Config, node: &Node) -> Result<String> {
  219. let blob = fetch_host_key_data(config, node).await?;
  220. let hash = Sha256::digest(&blob);
  221. Ok(format!("SHA256:{}", STANDARD.encode(hash)))
  222. }
  223. /// Fetches a node's full public key line (e.g. `ssh-ed25519 AAAA...`).
  224. /// Consulted over a connection that already verifies the pinned host key,
  225. /// so the returned key can be trusted to write into a `known_hosts` file.
  226. pub async fn fetch_host_pubkey(config: &Config, node: &Node) -> Result<String> {
  227. let blob = fetch_host_key_data(config, node).await?;
  228. Ok(String::from_utf8_lossy(&blob).trim().to_string())
  229. }
  230. /// Raw host-key bytes captured via a fresh connection (used to derive the
  231. /// fingerprint or the full public key line).
  232. async fn fetch_host_key_data(config: &Config, node: &Node) -> Result<Vec<u8>> {
  233. use std::sync::Mutex;
  234. let captured = Arc::new(Mutex::new(None::<Vec<u8>>));
  235. let captured_clone = captured.clone();
  236. struct CaptureHandler {
  237. captured: Arc<Mutex<Option<Vec<u8>>>>,
  238. }
  239. impl client::Handler for CaptureHandler {
  240. type Error = SshError;
  241. async fn check_server_key(
  242. &mut self,
  243. key: &russh::keys::PublicKey,
  244. ) -> Result<bool, Self::Error> {
  245. *self.captured.lock().unwrap() = Some(key.to_string().into_bytes());
  246. Ok(true)
  247. }
  248. }
  249. let key = cached_secret_key(&config.ssh_key_path)?;
  250. let ssh_config = russh::client::Config::default();
  251. let handler = CaptureHandler {
  252. captured: captured_clone,
  253. };
  254. // Apply the same connect timeout as `ssh_session_with_timeout` so an
  255. // unreachable node fails fast instead of hanging `health --fingerprint`.
  256. let mut session = tokio::time::timeout(
  257. CONNECT_TIMEOUT,
  258. russh::client::connect(Arc::new(ssh_config), (node.ssh_ip.as_str(), 22), handler),
  259. )
  260. .await
  261. .context(format!(
  262. "SSH connect to {}:{} timed out after {}s",
  263. node.ssh_ip,
  264. 22,
  265. CONNECT_TIMEOUT.as_secs()
  266. ))??;
  267. session
  268. .authenticate_publickey(&config.ssh_user, PrivateKeyWithHashAlg::new(key, None))
  269. .await?;
  270. captured
  271. .lock()
  272. .unwrap()
  273. .take()
  274. .context("No server key captured")
  275. }
  276. pub(crate) fn shq(s: &str) -> String {
  277. // A value that is already a complete single-quoted shell word (e.g. a recipe
  278. // arg the operator pre-quoted, like `'{"json":1}'`) is self-delimiting and
  279. // needs no escaping: single quotes already suppress every metachar/interp,
  280. // so wrapping it again would corrupt the token (the old `if starts_with('''`
  281. // special case). Everything else is wrapped in single quotes with any
  282. // interior quote escaped.
  283. if is_single_quoted_token(s) {
  284. return s.to_string();
  285. }
  286. let mut out = String::with_capacity(s.len() + 2);
  287. out.push('\'');
  288. for c in s.chars() {
  289. if c == '\'' {
  290. out.push_str(r"'\''");
  291. } else {
  292. out.push(c);
  293. }
  294. }
  295. out.push('\'');
  296. out
  297. }
  298. /// True when `s` is a well-formed single-quoted shell word: it starts and ends
  299. /// with a single quote and its interior contains no (unescaped) quote. A valid
  300. /// single-quoted token cannot inject — the shell treats its whole body as one
  301. /// literal argument.
  302. fn is_single_quoted_token(s: &str) -> bool {
  303. let b = s.as_bytes();
  304. b.len() >= 2 && b[0] == b'\'' && b[b.len() - 1] == b'\'' && !s[1..s.len() - 1].contains('\'')
  305. }
  306. /// Upload an in-memory script body to a node and make it executable.
  307. ///
  308. /// Cheaper than [`upload_dir`]: no tar, just base64-encode the body and
  309. /// pipe through SSH for decoding into `remote_path`, then `chmod +x`. Used
  310. /// by the built-in script runner (`before_serve` / `after_stop`) to deploy
  311. /// a baked-in script to each node before invoking it via `bash`.
  312. pub async fn upload_script(
  313. config: &Config,
  314. node: &Node,
  315. body: &str,
  316. remote_path: &str,
  317. dry_run: bool,
  318. ) -> Result<()> {
  319. if dry_run {
  320. println!(
  321. " ssh {}@{}: upload script ({} bytes) -> {}",
  322. config.ssh_user,
  323. node.ssh_ip,
  324. body.len(),
  325. remote_path
  326. );
  327. return Ok(());
  328. }
  329. let b64 = STANDARD.encode(body.as_bytes());
  330. // `remote_path` is shell-quoted; the base64 body is safe inside single
  331. // quotes (charset A-Za-z0-9+/=).
  332. let cmd = format!(
  333. "printf '%s' '{}' | base64 -d > {} && chmod +x {}",
  334. b64,
  335. shq(remote_path),
  336. shq(remote_path)
  337. );
  338. run_ssh(config, node, cmd, false).await?;
  339. Ok(())
  340. }
  341. /// Upload a local directory to a remote node by tarring, base64-encoding,
  342. /// and piping the result through SSH for decoding and extraction.
  343. ///
  344. /// The remote directory is created if it doesn't exist.
  345. pub async fn upload_dir(
  346. config: &Config,
  347. node: &Node,
  348. local_dir: &Path,
  349. remote_dir: &str,
  350. dry_run: bool,
  351. ) -> Result<()> {
  352. if dry_run {
  353. println!(
  354. " ssh {}@{}: tar -> base64 -> decode -> extract -> {} (dry-run)",
  355. config.ssh_user, node.ssh_ip, remote_dir
  356. );
  357. return Ok(());
  358. }
  359. // Tar only the *contents* of the directory (not the directory itself)
  360. // so that files extract directly into remote_dir.
  361. let output = std::process::Command::new("tar")
  362. .args(["czf", "-", "--dereference", "-C"])
  363. .arg(local_dir)
  364. .arg(".")
  365. .output()
  366. .context(format!("Failed to tar directory '{}'", local_dir.display()))?;
  367. if !output.status.success() {
  368. anyhow::bail!(
  369. "tar failed on '{}': {}",
  370. local_dir.display(),
  371. String::from_utf8_lossy(&output.stderr)
  372. );
  373. }
  374. let b64 = STANDARD.encode(&output.stdout);
  375. // Base64 is safe inside single quotes (charset: A-Za-z0-9+/=).
  376. // `printf '%s'` is portable and doesn't interpret escape sequences.
  377. //
  378. // The full payload can exceed the platform ARG_MAX (e.g. from-source build
  379. // dirs with large hash-lock files -> ~250KB base64 > the ~256KB limit the
  380. // remote `zsh -c` shell enforces), so we never inline it all in one argv.
  381. // Instead stream it in fixed-size chunks via `>>` appends to a temp file on
  382. // the node, then base64-decode + extract in a final command.
  383. let tmp = format!("/tmp/sm121-up-{}.b64", std::process::id());
  384. const CHUNK: usize = 60_000;
  385. let chunks: Vec<&str> = b64
  386. .as_bytes()
  387. .chunks(CHUNK)
  388. .map(|c| std::str::from_utf8(c).expect("base64 is ASCII"))
  389. .collect();
  390. for (i, piece) in chunks.iter().enumerate() {
  391. let op = if i == 0 { ">" } else { ">>" };
  392. let cmd = format!("printf '%s' '{}' {} {}", piece, op, shq(&tmp));
  393. run_ssh(config, node, cmd, false).await?;
  394. }
  395. // `remote_dir` is shell-quoted — a path with spaces or metachars would
  396. // otherwise break `mkdir -p` / `cd`.
  397. let cmd = format!(
  398. "mkdir -p {dir} && cd {dir} && base64 -d < {tmp} | tar xzf - && rm -f {tmp}",
  399. dir = shq(remote_dir),
  400. tmp = shq(&tmp)
  401. );
  402. run_ssh(config, node, cmd, false).await?;
  403. Ok(())
  404. }
  405. /// Loads an SSH private key from the given path.
  406. pub fn load_secret_key(path: &Path) -> Result<russh::keys::PrivateKey> {
  407. russh_load_secret_key(path, None).context("Failed to load SSH key")
  408. }
  409. /// Parsed private keys, cached by canonical path. The web dashboard opens a
  410. /// fresh SSH session per node every 30 s (the fleet probe) — re-reading and
  411. /// re-parsing the key file on every session was pure waste; the key is
  412. /// static per deployment, so parse it once and share the `Arc`.
  413. static KEY_CACHE: std::sync::LazyLock<
  414. std::sync::Mutex<std::collections::HashMap<std::path::PathBuf, Arc<russh::keys::PrivateKey>>>,
  415. > = std::sync::LazyLock::new(|| std::sync::Mutex::new(std::collections::HashMap::new()));
  416. fn cached_secret_key(path: &Path) -> Result<Arc<russh::keys::PrivateKey>> {
  417. let mut cache = KEY_CACHE.lock().unwrap();
  418. let key_path = path.canonicalize().unwrap_or_else(|_| path.to_path_buf());
  419. if let Some(k) = cache.get(&key_path) {
  420. return Ok(Arc::clone(k));
  421. }
  422. let k = Arc::new(load_secret_key(path)?);
  423. cache.insert(key_path, Arc::clone(&k));
  424. Ok(k)
  425. }
  426. /// Handler that verifies the server's host key against an expected fingerprint.
  427. pub struct VerifyingHandler {
  428. pub expected_key: Arc<std::sync::Mutex<Option<String>>>,
  429. }
  430. impl client::Handler for VerifyingHandler {
  431. type Error = SshError;
  432. async fn check_server_key(
  433. &mut self,
  434. server_public_key: &russh::keys::PublicKey,
  435. ) -> Result<bool, Self::Error> {
  436. let expected = self.expected_key.lock().unwrap().clone();
  437. if let Some(ref expected_fp) = expected {
  438. let key_bytes = server_public_key.to_string().into_bytes();
  439. let hash = Sha256::digest(&key_bytes);
  440. let fp = format!("SHA256:{}", STANDARD.encode(hash));
  441. if &fp != expected_fp {
  442. return Err(SshError::Kex);
  443. }
  444. }
  445. Ok(true)
  446. }
  447. }
  448. #[cfg(test)]
  449. #[path = "ssh_tests.rs"]
  450. mod tests;