omnyssh_core/ssh/session.rs
1//! Async SSH session management via russh.
2//!
3//! Provides [`SshSession`] — a thin wrapper around a russh client handle that
4//! supports connecting, executing commands, and graceful disconnect.
5//! Authentication order: identity file → SSH agent → failure.
6//!
7//! Connection and command timeouts are enforced:
8//! - Connect timeout: 10 seconds
9//! - Command timeout: 30 seconds
10
11use std::sync::Arc;
12use std::time::Duration;
13
14use anyhow::{anyhow, Context};
15use async_trait::async_trait;
16use russh::client::{self, Handle};
17use russh::ChannelMsg;
18use tokio::time;
19
20use crate::ssh::client::Host;
21
22// ---------------------------------------------------------------------------
23// russh Handler implementation
24// ---------------------------------------------------------------------------
25
26/// Shared russh client handler used by every native SSH path (metrics, SFTP,
27/// terminal).
28///
29/// Verifies the server's host key against `~/.ssh/known_hosts`.
30/// Unknown hosts are recorded on first connection (trust on first use);
31/// changed keys are rejected.
32pub(crate) struct KnownHostsHandler {
33 /// Hostname used for known_hosts lookup.
34 host: String,
35 /// Port used for known_hosts lookup.
36 port: u16,
37}
38
39#[async_trait]
40impl client::Handler for KnownHostsHandler {
41 type Error = russh::Error;
42
43 async fn check_server_key(
44 &mut self,
45 server_public_key: &russh::keys::key::PublicKey,
46 ) -> Result<bool, Self::Error> {
47 match russh::keys::check_known_hosts(&self.host, self.port, server_public_key) {
48 // Key is in known_hosts and matches.
49 Ok(true) => Ok(true),
50 // Host not seen before — record the key (trust on first use) so a
51 // later key change is detected, then accept. Recording is
52 // best-effort: a connection must not fail just because
53 // known_hosts is unwritable.
54 Ok(false) => {
55 tracing::warn!(
56 host = %self.host,
57 port = self.port,
58 "Accepting unknown host key for {} (Trust On First Use)", self.host
59 );
60 match russh::keys::known_hosts::learn_known_hosts(
61 &self.host,
62 self.port,
63 server_public_key,
64 ) {
65 Ok(()) => tracing::info!(
66 host = %self.host,
67 port = self.port,
68 "recorded new host key in known_hosts"
69 ),
70 Err(e) => tracing::warn!(
71 host = %self.host,
72 error = %e,
73 "could not record host key in known_hosts"
74 ),
75 }
76 Ok(true)
77 }
78 // A previously recorded key changed — refuse; possible MITM.
79 Err(russh::keys::Error::KeyChanged { .. }) => {
80 tracing::warn!(
81 host = %self.host,
82 port = self.port,
83 "server key mismatch in known_hosts — possible MITM attack, refusing connection"
84 );
85 Ok(false)
86 }
87 // Unreadable or corrupt known_hosts — fail closed rather than
88 // accept an unverified key.
89 Err(e) => {
90 tracing::warn!(
91 host = %self.host,
92 error = %e,
93 "known_hosts check failed; refusing connection"
94 );
95 Ok(false)
96 }
97 }
98 }
99}
100
101// ---------------------------------------------------------------------------
102// SshSession
103// ---------------------------------------------------------------------------
104
105/// An authenticated SSH session ready for command execution.
106///
107/// Holds the russh client handle for the duration of its lifetime.
108/// Drop → the connection is cleaned up by russh's internal tasks.
109///
110/// Wrapped in Arc to allow sharing across multiple operations (discovery + metrics).
111#[derive(Clone)]
112pub struct SshSession {
113 handle: Arc<Handle<KnownHostsHandler>>,
114}
115
116impl SshSession {
117 /// Connect and authenticate to `host`.
118 ///
119 /// Authentication is attempted in order:
120 /// 1. SSH agent (unix only, via `SSH_AUTH_SOCK`).
121 /// 2. Identity file specified in the host config (`identity_file`).
122 /// 3. Default key files (`~/.ssh/id_ed25519`, `id_rsa`, etc.).
123 /// 4. Password (if provided in host config).
124 ///
125 /// Returns an error when no method succeeds or the connection times out.
126 ///
127 /// # Errors
128 /// - Connection timeout (> 10 s)
129 /// - Authentication failure
130 /// - Network error
131 pub async fn connect(host: &Host) -> anyhow::Result<Self> {
132 Ok(Self {
133 handle: Arc::new(connect_and_auth(host).await?),
134 })
135 }
136
137 /// Execute a shell command on the remote host and return its stdout.
138 ///
139 /// A new SSH channel is opened for each call so sessions can be
140 /// reused across multiple commands. The remote exit status is ignored —
141 /// use [`SshSession::run_command_checked`] when it carries the result.
142 ///
143 /// # Errors
144 /// Returns an error on channel failure or if the command times out (30 s).
145 pub async fn run_command(&self, cmd: &str) -> anyhow::Result<String> {
146 Ok(self.exec(cmd).await?.0)
147 }
148
149 /// Like [`SshSession::run_command`] but returns an error when the remote
150 /// command exits with a non-zero status. Use for `test`-style probes whose
151 /// exit code is the answer (e.g. `sudo -n true`).
152 ///
153 /// # Errors
154 /// As [`SshSession::run_command`], plus a non-zero remote exit status.
155 pub async fn run_command_checked(&self, cmd: &str) -> anyhow::Result<String> {
156 let (output, status) = self.exec(cmd).await?;
157 match status {
158 // A missing exit status is treated as success — failing a command
159 // that likely worked is worse than missing a rare edge case.
160 None | Some(0) => Ok(output),
161 Some(code) => Err(anyhow!("remote command exited with status {code}")),
162 }
163 }
164
165 /// Opens a channel, runs `cmd`, and returns its stdout and exit status.
166 async fn exec(&self, cmd: &str) -> anyhow::Result<(String, Option<u32>)> {
167 let mut channel = self
168 .handle
169 .channel_open_session()
170 .await
171 .context("open SSH channel")?;
172
173 channel.exec(true, cmd).await.context("exec SSH command")?;
174
175 time::timeout(Duration::from_secs(30), collect_output(&mut channel))
176 .await
177 .map_err(|_| anyhow!("command timed out (30 s): {}", cmd))?
178 .context("read command output")
179 }
180
181 /// Opens a new SSH channel, requests the SFTP subsystem, and returns the
182 /// channel as an async stream suitable for [`russh_sftp::client::SftpSession::new`].
183 ///
184 /// The `SshSession` **must** remain alive for the entire lifetime of the
185 /// SFTP session — dropping it closes the underlying TCP connection.
186 ///
187 /// # Errors
188 /// Returns an error if the channel cannot be opened or if the server rejects
189 /// the SFTP subsystem request.
190 pub async fn open_sftp_channel(
191 &self,
192 ) -> anyhow::Result<russh::ChannelStream<russh::client::Msg>> {
193 let channel = self
194 .handle
195 .channel_open_session()
196 .await
197 .context("open SFTP session channel")?;
198 channel
199 .request_subsystem(true, "sftp")
200 .await
201 .context("request SFTP subsystem")?;
202 Ok(channel.into_stream())
203 }
204
205 /// Gracefully close the SSH connection.
206 pub async fn disconnect(self) {
207 let _ = self
208 .handle
209 .disconnect(russh::Disconnect::ByApplication, "", "en")
210 .await;
211 }
212}
213
214// ---------------------------------------------------------------------------
215// Connection + authentication
216// ---------------------------------------------------------------------------
217
218/// Connect to `host`, verify its host key, and authenticate.
219///
220/// Shared by [`SshSession::connect`] (metrics/SFTP) and the terminal so every
221/// native SSH path honors the same keys, agent, passwords, and known_hosts
222/// policy.
223///
224/// # Errors
225/// Connection timeout (> 10 s), host-key rejection, or authentication failure.
226pub(crate) async fn connect_and_auth(host: &Host) -> anyhow::Result<Handle<KnownHostsHandler>> {
227 let config = Arc::new(client::Config {
228 inactivity_timeout: Some(Duration::from_secs(30)),
229 keepalive_interval: Some(Duration::from_secs(15)),
230 keepalive_max: 3,
231 ..Default::default()
232 });
233
234 let addr = format!("{}:{}", host.hostname, host.port);
235 let mut handle = time::timeout(
236 Duration::from_secs(10),
237 client::connect(
238 config,
239 addr,
240 KnownHostsHandler {
241 host: host.hostname.clone(),
242 port: host.port,
243 },
244 ),
245 )
246 .await
247 .map_err(|_| anyhow!("SSH connection timed out (10 s)"))?
248 .context("SSH connection failed")?;
249
250 if !authenticate(&mut handle, host).await? {
251 return Err(anyhow!("SSH authentication failed for {}", host.name));
252 }
253 Ok(handle)
254}
255
256// ---------------------------------------------------------------------------
257// Authentication helpers
258// ---------------------------------------------------------------------------
259
260async fn authenticate(handle: &mut Handle<KnownHostsHandler>, host: &Host) -> anyhow::Result<bool> {
261 let user = host.user.clone();
262
263 // 1. Try SSH agent first — it handles passphrase-protected keys and is the
264 // most common auth method for non-interactive clients.
265 #[cfg(unix)]
266 {
267 if try_agent_auth(handle, &user).await.unwrap_or(false) {
268 return Ok(true);
269 }
270 }
271
272 // 2. Try explicit identity_file from host config.
273 if let Some(key_path) = &host.identity_file {
274 let path = expand_tilde(key_path);
275 if try_key_auth(handle, &user, &path).await.unwrap_or(false) {
276 return Ok(true);
277 }
278 }
279
280 // 3. Try default key files — mirrors what the `ssh` binary does when no
281 // -i flag is given. Skips files that don't exist.
282 for key_path in default_key_paths() {
283 if key_path.exists() {
284 let path_str = key_path.to_string_lossy().into_owned();
285 if try_key_auth(handle, &user, &path_str)
286 .await
287 .unwrap_or(false)
288 {
289 return Ok(true);
290 }
291 }
292 }
293
294 // 4. Try password authentication if provided.
295 // Password auth is NOT recommended for production use but is required for
296 // the initial connection before setting up key-based auth.
297 if let Some(password) = &host.password {
298 if try_password_auth(handle, &user, password)
299 .await
300 .unwrap_or(false)
301 {
302 tracing::info!(
303 host = %host.name,
304 "Connected via password authentication — consider setting up SSH key"
305 );
306 return Ok(true);
307 }
308 }
309
310 Ok(false)
311}
312
313/// Returns the standard default SSH private key paths in priority order.
314fn default_key_paths() -> Vec<std::path::PathBuf> {
315 let Some(home) = dirs::home_dir() else {
316 return vec![];
317 };
318 let ssh = home.join(".ssh");
319 [
320 "id_ed25519",
321 "id_rsa",
322 "id_ecdsa",
323 "id_ecdsa_sk",
324 "id_ed25519_sk",
325 ]
326 .iter()
327 .map(|name| ssh.join(name))
328 .collect()
329}
330
331async fn try_key_auth(
332 handle: &mut Handle<KnownHostsHandler>,
333 user: &str,
334 key_path: &str,
335) -> anyhow::Result<bool> {
336 // load_secret_key is synchronous (file I/O) — offload to blocking pool.
337 let path = key_path.to_string();
338 let key_pair = tokio::task::spawn_blocking(move || {
339 russh::keys::load_secret_key(&path, None).with_context(|| format!("load key from {path}"))
340 })
341 .await
342 .context("spawn_blocking panicked")??;
343
344 let ok = handle
345 .authenticate_publickey(user, Arc::new(key_pair))
346 .await
347 .context("authenticate_publickey")?;
348 Ok(ok)
349}
350
351#[cfg(unix)]
352async fn try_agent_auth(
353 handle: &mut Handle<KnownHostsHandler>,
354 user: &str,
355) -> anyhow::Result<bool> {
356 use russh::keys::agent::client::AgentClient;
357
358 let mut agent = AgentClient::connect_env()
359 .await
360 .context("connect to SSH agent")?;
361
362 let identities = agent
363 .request_identities()
364 .await
365 .context("request agent identities")?;
366
367 for pubkey in identities {
368 let (agent_back, result) = handle.authenticate_future(user, pubkey, agent).await;
369 agent = agent_back;
370 match result {
371 Ok(true) => return Ok(true),
372 Ok(false) => continue,
373 Err(_) => continue,
374 }
375 }
376 Ok(false)
377}
378
379/// Try password-based authentication.
380///
381/// # Errors
382/// Returns an error if the authentication attempt fails.
383async fn try_password_auth(
384 handle: &mut Handle<KnownHostsHandler>,
385 user: &str,
386 password: &str,
387) -> anyhow::Result<bool> {
388 let ok = handle
389 .authenticate_password(user, password)
390 .await
391 .context("authenticate with password")?;
392 Ok(ok)
393}
394
395// ---------------------------------------------------------------------------
396// Output collection
397// ---------------------------------------------------------------------------
398
399async fn collect_output(
400 channel: &mut russh::Channel<russh::client::Msg>,
401) -> anyhow::Result<(String, Option<u32>)> {
402 let mut buf = Vec::new();
403 let mut exit_status = None;
404 loop {
405 match channel.wait().await {
406 Some(ChannelMsg::Data { ref data }) => {
407 buf.extend_from_slice(data);
408 }
409 Some(ChannelMsg::ExtendedData { .. }) => {
410 // stderr — discard to avoid corrupting stdout-only parser input
411 }
412 Some(ChannelMsg::ExitStatus { exit_status: code }) => {
413 // Record it but keep reading: trailing stdout may still arrive
414 // before the channel is closed.
415 exit_status = Some(code);
416 }
417 Some(ChannelMsg::Eof) => {
418 // Continue reading — ExitStatus may arrive after Eof.
419 }
420 Some(ChannelMsg::Close) | None => break,
421 _ => {}
422 }
423 }
424 // Use .lines() semantics: replace \r\n → \n for cross-platform safety.
425 let raw = String::from_utf8_lossy(&buf);
426 let normalised: String = raw.lines().flat_map(|l| [l, "\n"]).collect();
427 Ok((normalised, exit_status))
428}
429
430// ---------------------------------------------------------------------------
431// Path helpers
432// ---------------------------------------------------------------------------
433
434fn expand_tilde(path: &str) -> String {
435 if path.starts_with("~/") || path == "~" {
436 if let Some(home) = dirs::home_dir() {
437 return path.replacen('~', &home.to_string_lossy(), 1);
438 }
439 }
440 path.to_string()
441}