ssh.rs 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409
  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. let key = load_secret_key(&config.ssh_key_path)?;
  33. let ssh_config = russh::client::Config {
  34. inactivity_timeout,
  35. ..Default::default()
  36. };
  37. // Refuse to connect without a pinned host key — a missing `host_key`
  38. // would otherwise skip verification entirely (MITM vector). Run
  39. // `sm121 init-host-keys` to discover and pin keys, or set `host_key`
  40. // manually in config.toml. (`fetch_host_key` / init-host-keys uses a
  41. // separate capturing handler and is intentionally exempt.)
  42. if node.host_key.is_none() {
  43. anyhow::bail!(
  44. "No host_key set for node '{}' ({}). Refusing to connect without \
  45. host-key verification. Run `sm121 init-host-keys --cluster <name>` \
  46. to discover and pin host keys, or set `host_key` manually in config.toml.",
  47. node.name,
  48. node.ssh_ip
  49. );
  50. }
  51. let expected_key = Arc::new(std::sync::Mutex::new(node.host_key.clone()));
  52. let handler = VerifyingHandler { expected_key };
  53. let mut session = tokio::time::timeout(
  54. CONNECT_TIMEOUT,
  55. russh::client::connect(Arc::new(ssh_config), (node.ssh_ip.as_str(), 22), handler),
  56. )
  57. .await
  58. .context(format!(
  59. "SSH connect to {}:{} timed out after {}s",
  60. node.ssh_ip,
  61. 22,
  62. CONNECT_TIMEOUT.as_secs()
  63. ))??;
  64. session
  65. .authenticate_publickey(
  66. &config.ssh_user,
  67. PrivateKeyWithHashAlg::new(Arc::new(key), None),
  68. )
  69. .await?;
  70. Ok(session)
  71. }
  72. /// Runs a command on a node via SSH and returns stdout.
  73. ///
  74. /// The remote command's exit status is tracked: if it is non-zero,
  75. /// an Err is returned whose `to_string()` includes the captured output
  76. /// for diagnosis. Callers that don't care about exit status can ignore
  77. /// the Result and inspect stdout via the returned `String`.
  78. pub async fn run_ssh(
  79. config: &Config,
  80. node: &Node,
  81. command: String,
  82. dry_run: bool,
  83. ) -> Result<String> {
  84. if dry_run {
  85. println!(" ssh {}@{}: {}", config.ssh_user, node.ssh_ip, command);
  86. return Ok(String::new());
  87. }
  88. let session = ssh_session(config, node).await?;
  89. let mut channel = session.channel_open_session().await?;
  90. channel.exec(true, command.as_bytes()).await?;
  91. let mut output = String::new();
  92. let mut exit_status: Option<u32> = None;
  93. while let Some(msg) = channel.wait().await {
  94. match msg {
  95. ChannelMsg::Data { data } => {
  96. output.push_str(&String::from_utf8_lossy(&data));
  97. }
  98. ChannelMsg::ExitStatus { exit_status: s } => {
  99. exit_status = Some(s);
  100. }
  101. _ => {}
  102. }
  103. }
  104. match exit_status {
  105. Some(0) | None => Ok(output),
  106. Some(code) => Err(anyhow::anyhow!(
  107. "ssh {}@{}: command exited with status {}\n command: {}\n output:\n{}",
  108. config.ssh_user,
  109. node.ssh_ip,
  110. code,
  111. command,
  112. output
  113. )),
  114. }
  115. }
  116. /// Runs a command via SSH with exponential backoff retry.
  117. pub async fn run_ssh_with_retry(
  118. config: &Config,
  119. node: &Node,
  120. command: String,
  121. dry_run: bool,
  122. max_retries: u32,
  123. ) -> Result<String> {
  124. let mut last_err = None;
  125. for attempt in 0..=max_retries {
  126. match run_ssh(config, node, command.clone(), dry_run).await {
  127. Ok(output) => return Ok(output),
  128. Err(e) => {
  129. warn!(
  130. "SSH attempt {}/{} to {} failed: {}",
  131. attempt + 1,
  132. max_retries + 1,
  133. node.name,
  134. e
  135. );
  136. last_err = Some(e);
  137. if attempt < max_retries {
  138. sleep(Duration::from_secs(2u64.pow(attempt))).await;
  139. }
  140. }
  141. }
  142. }
  143. Err(last_err.unwrap())
  144. }
  145. /// Runs a command via SSH and streams output to stdout (for follow mode).
  146. /// Uses a session with no inactivity timeout so long-lived streams are not killed.
  147. pub async fn run_ssh_follow(
  148. config: &Config,
  149. node: &Node,
  150. command: String,
  151. dry_run: bool,
  152. ) -> Result<()> {
  153. if dry_run {
  154. println!(" ssh {}@{}: {} -f", config.ssh_user, node.ssh_ip, command);
  155. return Ok(());
  156. }
  157. let session = ssh_session_follow(config, node).await?;
  158. let mut channel = session.channel_open_session().await?;
  159. channel.exec(true, command.as_bytes()).await?;
  160. while let Some(msg) = channel.wait().await {
  161. if let ChannelMsg::Data { data } = msg {
  162. print!("{}", String::from_utf8_lossy(&data));
  163. }
  164. }
  165. Ok(())
  166. }
  167. /// Runs a command via SSH with no inactivity timeout — suitable for long,
  168. /// streaming commands like `docker save | ssh docker load`.
  169. pub async fn run_ssh_long_running(
  170. config: &Config,
  171. node: &Node,
  172. command: String,
  173. dry_run: bool,
  174. ) -> Result<String> {
  175. if dry_run {
  176. println!(
  177. " ssh {}@{}: {} (long-running)",
  178. config.ssh_user, node.ssh_ip, command
  179. );
  180. return Ok(String::new());
  181. }
  182. let session = ssh_session_follow(config, node).await?;
  183. let mut channel = session.channel_open_session().await?;
  184. channel.exec(true, command.as_bytes()).await?;
  185. let mut output = String::new();
  186. let mut exit_status: Option<u32> = None;
  187. while let Some(msg) = channel.wait().await {
  188. match msg {
  189. ChannelMsg::Data { data } => output.push_str(&String::from_utf8_lossy(&data)),
  190. ChannelMsg::ExitStatus { exit_status: s } => exit_status = Some(s),
  191. _ => {}
  192. }
  193. }
  194. match exit_status {
  195. Some(0) | None => Ok(output),
  196. Some(code) => Err(anyhow::anyhow!(
  197. "ssh {}@{}: command exited with status {}\n command: {}",
  198. config.ssh_user,
  199. node.ssh_ip,
  200. code,
  201. command
  202. )),
  203. }
  204. }
  205. /// Fetches the SSH host key fingerprint from a node.
  206. pub async fn fetch_host_key(config: &Config, node: &Node) -> Result<String> {
  207. use std::sync::Mutex;
  208. let captured = Arc::new(Mutex::new(None::<Vec<u8>>));
  209. let captured_clone = captured.clone();
  210. struct CaptureHandler {
  211. captured: Arc<Mutex<Option<Vec<u8>>>>,
  212. }
  213. impl client::Handler for CaptureHandler {
  214. type Error = SshError;
  215. async fn check_server_key(
  216. &mut self,
  217. key: &russh::keys::PublicKey,
  218. ) -> Result<bool, Self::Error> {
  219. *self.captured.lock().unwrap() = Some(key.to_string().into_bytes());
  220. Ok(true)
  221. }
  222. }
  223. let key = load_secret_key(&config.ssh_key_path)?;
  224. let ssh_config = russh::client::Config::default();
  225. let handler = CaptureHandler {
  226. captured: captured_clone,
  227. };
  228. // Apply the same connect timeout as `ssh_session_with_timeout` so an
  229. // unreachable node fails fast instead of hanging `health --fingerprint`.
  230. let mut session = tokio::time::timeout(
  231. CONNECT_TIMEOUT,
  232. russh::client::connect(Arc::new(ssh_config), (node.ssh_ip.as_str(), 22), handler),
  233. )
  234. .await
  235. .context(format!(
  236. "SSH connect to {}:{} timed out after {}s",
  237. node.ssh_ip,
  238. 22,
  239. CONNECT_TIMEOUT.as_secs()
  240. ))??;
  241. session
  242. .authenticate_publickey(
  243. &config.ssh_user,
  244. PrivateKeyWithHashAlg::new(Arc::new(key), None),
  245. )
  246. .await?;
  247. let blob = captured
  248. .lock()
  249. .unwrap()
  250. .take()
  251. .context("No server key captured")?;
  252. let hash = Sha256::digest(&blob);
  253. let fp = format!("SHA256:{}", STANDARD.encode(hash));
  254. Ok(fp)
  255. }
  256. pub(crate) fn shq(s: &str) -> String {
  257. let mut out = String::with_capacity(s.len() + 2);
  258. out.push('\'');
  259. for c in s.chars() {
  260. if c == '\'' {
  261. out.push_str(r"'\''");
  262. } else {
  263. out.push(c);
  264. }
  265. }
  266. out.push('\'');
  267. out
  268. }
  269. /// Upload an in-memory script body to a node and make it executable.
  270. ///
  271. /// Cheaper than [`upload_dir`]: no tar, just base64-encode the body and
  272. /// pipe through SSH for decoding into `remote_path`, then `chmod +x`. Used
  273. /// by the built-in script runner (`before_serve` / `after_stop`) to deploy
  274. /// a baked-in script to each node before invoking it via `bash`.
  275. pub async fn upload_script(
  276. config: &Config,
  277. node: &Node,
  278. body: &str,
  279. remote_path: &str,
  280. dry_run: bool,
  281. ) -> Result<()> {
  282. if dry_run {
  283. println!(
  284. " ssh {}@{}: upload script ({} bytes) -> {}",
  285. config.ssh_user,
  286. node.ssh_ip,
  287. body.len(),
  288. remote_path
  289. );
  290. return Ok(());
  291. }
  292. let b64 = STANDARD.encode(body.as_bytes());
  293. // `remote_path` is shell-quoted; the base64 body is safe inside single
  294. // quotes (charset A-Za-z0-9+/=).
  295. let cmd = format!(
  296. "printf '%s' '{}' | base64 -d > {} && chmod +x {}",
  297. b64,
  298. shq(remote_path),
  299. shq(remote_path)
  300. );
  301. run_ssh(config, node, cmd, false).await?;
  302. Ok(())
  303. }
  304. /// Upload a local directory to a remote node by tarring, base64-encoding,
  305. /// and piping the result through SSH for decoding and extraction.
  306. ///
  307. /// The remote directory is created if it doesn't exist.
  308. pub async fn upload_dir(
  309. config: &Config,
  310. node: &Node,
  311. local_dir: &Path,
  312. remote_dir: &str,
  313. dry_run: bool,
  314. ) -> Result<()> {
  315. if dry_run {
  316. println!(
  317. " ssh {}@{}: tar -> base64 -> decode -> extract -> {} (dry-run)",
  318. config.ssh_user, node.ssh_ip, remote_dir
  319. );
  320. return Ok(());
  321. }
  322. // Tar only the *contents* of the directory (not the directory itself)
  323. // so that files extract directly into remote_dir.
  324. let output = std::process::Command::new("tar")
  325. .args(["czf", "-", "--dereference", "-C"])
  326. .arg(local_dir)
  327. .arg(".")
  328. .output()
  329. .context(format!("Failed to tar directory '{}'", local_dir.display()))?;
  330. if !output.status.success() {
  331. anyhow::bail!(
  332. "tar failed on '{}': {}",
  333. local_dir.display(),
  334. String::from_utf8_lossy(&output.stderr)
  335. );
  336. }
  337. let b64 = STANDARD.encode(&output.stdout);
  338. // Base64 is safe inside single quotes (charset: A-Za-z0-9+/=).
  339. // `printf '%s'` is portable and doesn't interpret escape sequences.
  340. // `remote_dir` is shell-quoted — a path with spaces or metachars would
  341. // otherwise break `mkdir -p` / `cd`.
  342. let cmd = format!(
  343. "mkdir -p {dir} && cd {dir} && printf '%s' '{b64}' | base64 -d | tar xzf -",
  344. dir = shq(remote_dir),
  345. b64 = b64
  346. );
  347. run_ssh(config, node, cmd, false).await?;
  348. Ok(())
  349. }
  350. /// Loads an SSH private key from the given path.
  351. pub fn load_secret_key(path: &Path) -> Result<russh::keys::PrivateKey> {
  352. russh_load_secret_key(path, None).context("Failed to load SSH key")
  353. }
  354. /// Handler that verifies the server's host key against an expected fingerprint.
  355. pub struct VerifyingHandler {
  356. pub expected_key: Arc<std::sync::Mutex<Option<String>>>,
  357. }
  358. impl client::Handler for VerifyingHandler {
  359. type Error = SshError;
  360. async fn check_server_key(
  361. &mut self,
  362. server_public_key: &russh::keys::PublicKey,
  363. ) -> Result<bool, Self::Error> {
  364. let expected = self.expected_key.lock().unwrap().clone();
  365. if let Some(ref expected_fp) = expected {
  366. let key_bytes = server_public_key.to_string().into_bytes();
  367. let hash = Sha256::digest(&key_bytes);
  368. let fp = format!("SHA256:{}", STANDARD.encode(hash));
  369. if &fp != expected_fp {
  370. return Err(SshError::Kex);
  371. }
  372. }
  373. Ok(true)
  374. }
  375. }