Skip to main content

ssh_cli/ssh/
client_handler.rs

1// SPDX-License-Identifier: MIT OR Apache-2.0
2// G-SSH-01 / G-SSH-09 / G-SSH-14: pure module — no `unsafe`.
3#![forbid(unsafe_code)]
4//! russh [`client::Handler`] with TOFU known_hosts and typed host-key errors.
5//!
6//! The handler is moved into `connect_stream`; product errors that cannot be
7//! expressed as `russh::Error` are stashed in [`HostKeyOutcome`] for the connect
8//! caller to recover after a failed handshake (G-SSH-01).
9
10use std::path::PathBuf;
11use std::sync::{Arc, Mutex};
12
13use crate::errors::SshCliError;
14use crate::ssh::connection::ConnectionConfig;
15
16/// Shared slot for host-key / TOFU failures observed inside the russh handler.
17///
18/// After `connect_stream` returns `Err`, the connect path must `take()` this
19/// value so agents receive typed [`SshCliError::HostKeyChanged`] (exit EX_NOPERM)
20/// instead of a generic handshake failure.
21pub type HostKeyOutcome = Arc<Mutex<Option<SshCliError>>>;
22
23/// Create an empty shared outcome slot.
24#[must_use]
25pub fn new_host_key_outcome() -> HostKeyOutcome {
26    Arc::new(Mutex::new(None))
27}
28
29/// Store a product error for the connect caller (best-effort if lock is poisoned).
30pub fn stash_host_key_error(outcome: &HostKeyOutcome, err: SshCliError) {
31    if let Ok(mut g) = outcome.lock() {
32        *g = Some(err);
33    }
34}
35
36/// Take a stashed host-key error if present.
37#[must_use]
38pub fn take_host_key_error(outcome: &HostKeyOutcome) -> Option<SshCliError> {
39    outcome.lock().ok().and_then(|mut g| g.take())
40}
41
42/// russh handler with TOFU known_hosts (or test-only always-trust when path is absent).
43pub struct ClientHandler {
44    host: String,
45    port: u16,
46    known_hosts_path: Option<PathBuf>,
47    replace_host_key: bool,
48    outcome: HostKeyOutcome,
49}
50
51impl ClientHandler {
52    /// Build a handler from connection config + shared outcome slot.
53    #[must_use]
54    pub fn new(cfg: &ConnectionConfig, outcome: HostKeyOutcome) -> Self {
55        Self {
56            host: cfg.host.as_str().to_owned(),
57            port: cfg.port.get(),
58            known_hosts_path: cfg.known_hosts_path.clone(),
59            replace_host_key: cfg.replace_host_key,
60            outcome,
61        }
62    }
63}
64
65impl russh::client::Handler for ClientHandler {
66    type Error = russh::Error;
67
68    async fn check_server_key(
69        &mut self,
70        server_key: &russh::keys::ssh_key::PublicKey,
71    ) -> Result<bool, Self::Error> {
72        let fingerprint = format!(
73            "{}",
74            server_key.fingerprint(russh::keys::HashAlg::Sha256)
75        );
76
77        // `take`: host-key check runs once per connection; move path, no clone.
78        let Some(path) = self.known_hosts_path.take() else {
79            // G-SSH-09: always-trust only in unit tests — product builds reject.
80            #[cfg(test)]
81            {
82                tracing::warn!("known_hosts missing: accepting host key (test mode)");
83                return Ok(true);
84            }
85            #[cfg(not(test))]
86            {
87                stash_host_key_error(
88                    &self.outcome,
89                    SshCliError::InvalidArgument(
90                        "known_hosts_path is required for host-key verification".into(),
91                    ),
92                );
93                tracing::error!("known_hosts path missing; rejecting host key (fail-closed)");
94                return Ok(false);
95            }
96        };
97
98        // G-NET: known_hosts load/save is sync FS + flock — keep it off the
99        // async worker so multi-host fan-out does not stall Tokio threads.
100        let host = self.host.clone();
101        let port = self.port;
102        let replace = self.replace_host_key;
103        let outcome = tokio::task::spawn_blocking(move || {
104            let mut kh = crate::ssh::known_hosts::KnownHosts::load(path)?;
105            crate::ssh::known_hosts::verify_tofu(&mut kh, &host, port, &fingerprint, replace)
106        })
107        .await;
108
109        match outcome {
110            Ok(Ok(true)) => Ok(true),
111            Ok(Ok(false)) => Ok(false),
112            Ok(Err(e)) => {
113                // G-SSH-01: preserve typed HostKeyChanged for the connect caller.
114                stash_host_key_error(&self.outcome, e);
115                tracing::error!("host key rejected");
116                Ok(false)
117            }
118            Err(e) => {
119                stash_host_key_error(
120                    &self.outcome,
121                    SshCliError::ConnectionFailed(format!("known_hosts task failed: {e}")),
122                );
123                tracing::error!(err = %e, "known_hosts task failed");
124                Ok(false)
125            }
126        }
127    }
128
129    async fn auth_banner(
130        &mut self,
131        banner: &str,
132        _session: &mut russh::client::Session,
133    ) -> Result<(), Self::Error> {
134        // G-SSH-14: surface pre-auth banners for diagnostics (truncate; no secrets expected).
135        const MAX: usize = 512;
136        let truncated = if banner.len() > MAX {
137            format!("{}…", &banner[..MAX])
138        } else {
139            banner.to_owned()
140        };
141        tracing::info!(banner = %truncated, "SSH auth banner");
142        Ok(())
143    }
144}