Skip to main content

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}