ssh.rs 14 KB

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