ssh.rs 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475
  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 = load_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(
  72. &config.ssh_user,
  73. PrivateKeyWithHashAlg::new(Arc::new(key), None),
  74. )
  75. .await?;
  76. Ok(session)
  77. }
  78. /// Runs a command on a node via SSH and returns stdout.
  79. ///
  80. /// The remote command's exit status is tracked: if it is non-zero,
  81. /// an Err is returned whose `to_string()` includes the captured output
  82. /// for diagnosis. Callers that don't care about exit status can ignore
  83. /// the Result and inspect stdout via the returned `String`.
  84. pub async fn run_ssh(
  85. config: &Config,
  86. node: &Node,
  87. command: String,
  88. dry_run: bool,
  89. ) -> Result<String> {
  90. if dry_run {
  91. println!(" ssh {}@{}: {}", config.ssh_user, node.ssh_ip, command);
  92. return Ok(String::new());
  93. }
  94. let session = ssh_session(config, node).await?;
  95. let mut channel = session.channel_open_session().await?;
  96. channel.exec(true, command.as_bytes()).await?;
  97. let mut output = String::new();
  98. let mut exit_status: Option<u32> = None;
  99. while let Some(msg) = channel.wait().await {
  100. match msg {
  101. ChannelMsg::Data { data } => {
  102. output.push_str(&String::from_utf8_lossy(&data));
  103. }
  104. ChannelMsg::ExtendedData { data, .. } => {
  105. output.push_str(&String::from_utf8_lossy(&data));
  106. }
  107. ChannelMsg::ExitStatus { exit_status: s } => {
  108. exit_status = Some(s);
  109. }
  110. _ => {}
  111. }
  112. }
  113. match exit_status {
  114. Some(0) | None => Ok(output),
  115. Some(code) => Err(anyhow::anyhow!(
  116. "ssh {}@{}: command exited with status {}\n command: {}\n output:\n{}",
  117. config.ssh_user,
  118. node.ssh_ip,
  119. code,
  120. command,
  121. output
  122. )),
  123. }
  124. }
  125. /// Runs a command via SSH with exponential backoff retry.
  126. pub async fn run_ssh_with_retry(
  127. config: &Config,
  128. node: &Node,
  129. command: String,
  130. dry_run: bool,
  131. max_retries: u32,
  132. ) -> Result<String> {
  133. let mut last_err = None;
  134. for attempt in 0..=max_retries {
  135. match run_ssh(config, node, command.clone(), dry_run).await {
  136. Ok(output) => return Ok(output),
  137. Err(e) => {
  138. warn!(
  139. "SSH attempt {}/{} to {} failed: {}",
  140. attempt + 1,
  141. max_retries + 1,
  142. node.name,
  143. e
  144. );
  145. last_err = Some(e);
  146. if attempt < max_retries {
  147. sleep(Duration::from_secs(2u64.pow(attempt))).await;
  148. }
  149. }
  150. }
  151. }
  152. Err(last_err.unwrap())
  153. }
  154. /// Runs a command via SSH and streams output to stdout (for follow mode).
  155. /// Uses a session with no inactivity timeout so long-lived streams are not killed.
  156. pub async fn run_ssh_follow(
  157. config: &Config,
  158. node: &Node,
  159. command: String,
  160. dry_run: bool,
  161. ) -> Result<()> {
  162. if dry_run {
  163. println!(" ssh {}@{}: {} -f", config.ssh_user, node.ssh_ip, command);
  164. return Ok(());
  165. }
  166. let session = ssh_session_follow(config, node).await?;
  167. let mut channel = session.channel_open_session().await?;
  168. channel.exec(true, command.as_bytes()).await?;
  169. while let Some(msg) = channel.wait().await {
  170. if let ChannelMsg::Data { data } = msg {
  171. print!("{}", String::from_utf8_lossy(&data));
  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 = load_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(
  269. &config.ssh_user,
  270. PrivateKeyWithHashAlg::new(Arc::new(key), None),
  271. )
  272. .await?;
  273. captured
  274. .lock()
  275. .unwrap()
  276. .take()
  277. .context("No server key captured")
  278. }
  279. pub(crate) fn shq(s: &str) -> String {
  280. // A value that is already a complete single-quoted shell word (e.g. a recipe
  281. // arg the operator pre-quoted, like `'{"json":1}'`) is self-delimiting and
  282. // needs no escaping: single quotes already suppress every metachar/interp,
  283. // so wrapping it again would corrupt the token (the old `if starts_with('''`
  284. // special case). Everything else is wrapped in single quotes with any
  285. // interior quote escaped.
  286. if is_single_quoted_token(s) {
  287. return s.to_string();
  288. }
  289. let mut out = String::with_capacity(s.len() + 2);
  290. out.push('\'');
  291. for c in s.chars() {
  292. if c == '\'' {
  293. out.push_str(r"'\''");
  294. } else {
  295. out.push(c);
  296. }
  297. }
  298. out.push('\'');
  299. out
  300. }
  301. /// True when `s` is a well-formed single-quoted shell word: it starts and ends
  302. /// with a single quote and its interior contains no (unescaped) quote. A valid
  303. /// single-quoted token cannot inject — the shell treats its whole body as one
  304. /// literal argument.
  305. fn is_single_quoted_token(s: &str) -> bool {
  306. let b = s.as_bytes();
  307. b.len() >= 2 && b[0] == b'\'' && b[b.len() - 1] == b'\'' && !s[1..s.len() - 1].contains('\'')
  308. }
  309. /// Upload an in-memory script body to a node and make it executable.
  310. ///
  311. /// Cheaper than [`upload_dir`]: no tar, just base64-encode the body and
  312. /// pipe through SSH for decoding into `remote_path`, then `chmod +x`. Used
  313. /// by the built-in script runner (`before_serve` / `after_stop`) to deploy
  314. /// a baked-in script to each node before invoking it via `bash`.
  315. pub async fn upload_script(
  316. config: &Config,
  317. node: &Node,
  318. body: &str,
  319. remote_path: &str,
  320. dry_run: bool,
  321. ) -> Result<()> {
  322. if dry_run {
  323. println!(
  324. " ssh {}@{}: upload script ({} bytes) -> {}",
  325. config.ssh_user,
  326. node.ssh_ip,
  327. body.len(),
  328. remote_path
  329. );
  330. return Ok(());
  331. }
  332. let b64 = STANDARD.encode(body.as_bytes());
  333. // `remote_path` is shell-quoted; the base64 body is safe inside single
  334. // quotes (charset A-Za-z0-9+/=).
  335. let cmd = format!(
  336. "printf '%s' '{}' | base64 -d > {} && chmod +x {}",
  337. b64,
  338. shq(remote_path),
  339. shq(remote_path)
  340. );
  341. run_ssh(config, node, cmd, false).await?;
  342. Ok(())
  343. }
  344. /// Upload a local directory to a remote node by tarring, base64-encoding,
  345. /// and piping the result through SSH for decoding and extraction.
  346. ///
  347. /// The remote directory is created if it doesn't exist.
  348. pub async fn upload_dir(
  349. config: &Config,
  350. node: &Node,
  351. local_dir: &Path,
  352. remote_dir: &str,
  353. dry_run: bool,
  354. ) -> Result<()> {
  355. if dry_run {
  356. println!(
  357. " ssh {}@{}: tar -> base64 -> decode -> extract -> {} (dry-run)",
  358. config.ssh_user, node.ssh_ip, remote_dir
  359. );
  360. return Ok(());
  361. }
  362. // Tar only the *contents* of the directory (not the directory itself)
  363. // so that files extract directly into remote_dir.
  364. let output = std::process::Command::new("tar")
  365. .args(["czf", "-", "--dereference", "-C"])
  366. .arg(local_dir)
  367. .arg(".")
  368. .output()
  369. .context(format!("Failed to tar directory '{}'", local_dir.display()))?;
  370. if !output.status.success() {
  371. anyhow::bail!(
  372. "tar failed on '{}': {}",
  373. local_dir.display(),
  374. String::from_utf8_lossy(&output.stderr)
  375. );
  376. }
  377. let b64 = STANDARD.encode(&output.stdout);
  378. // Base64 is safe inside single quotes (charset: A-Za-z0-9+/=).
  379. // `printf '%s'` is portable and doesn't interpret escape sequences.
  380. //
  381. // The full payload can exceed the platform ARG_MAX (e.g. from-source build
  382. // dirs with large hash-lock files -> ~250KB base64 > the ~256KB limit the
  383. // remote `zsh -c` shell enforces), so we never inline it all in one argv.
  384. // Instead stream it in fixed-size chunks via `>>` appends to a temp file on
  385. // the node, then base64-decode + extract in a final command.
  386. let tmp = format!("/tmp/sm121-up-{}.b64", std::process::id());
  387. const CHUNK: usize = 60_000;
  388. let chunks: Vec<&str> = b64
  389. .as_bytes()
  390. .chunks(CHUNK)
  391. .map(|c| std::str::from_utf8(c).expect("base64 is ASCII"))
  392. .collect();
  393. for (i, piece) in chunks.iter().enumerate() {
  394. let op = if i == 0 { ">" } else { ">>" };
  395. let cmd = format!("printf '%s' '{}' {} {}", piece, op, shq(&tmp));
  396. run_ssh(config, node, cmd, false).await?;
  397. }
  398. // `remote_dir` is shell-quoted — a path with spaces or metachars would
  399. // otherwise break `mkdir -p` / `cd`.
  400. let cmd = format!(
  401. "mkdir -p {dir} && cd {dir} && base64 -d < {tmp} | tar xzf - && rm -f {tmp}",
  402. dir = shq(remote_dir),
  403. tmp = shq(&tmp)
  404. );
  405. run_ssh(config, node, cmd, false).await?;
  406. Ok(())
  407. }
  408. /// Loads an SSH private key from the given path.
  409. pub fn load_secret_key(path: &Path) -> Result<russh::keys::PrivateKey> {
  410. russh_load_secret_key(path, None).context("Failed to load SSH key")
  411. }
  412. /// Handler that verifies the server's host key against an expected fingerprint.
  413. pub struct VerifyingHandler {
  414. pub expected_key: Arc<std::sync::Mutex<Option<String>>>,
  415. }
  416. impl client::Handler for VerifyingHandler {
  417. type Error = SshError;
  418. async fn check_server_key(
  419. &mut self,
  420. server_public_key: &russh::keys::PublicKey,
  421. ) -> Result<bool, Self::Error> {
  422. let expected = self.expected_key.lock().unwrap().clone();
  423. if let Some(ref expected_fp) = expected {
  424. let key_bytes = server_public_key.to_string().into_bytes();
  425. let hash = Sha256::digest(&key_bytes);
  426. let fp = format!("SHA256:{}", STANDARD.encode(hash));
  427. if &fp != expected_fp {
  428. return Err(SshError::Kex);
  429. }
  430. }
  431. Ok(true)
  432. }
  433. }
  434. #[cfg(test)]
  435. #[path = "ssh_tests.rs"]
  436. mod tests;