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!("{}", server_key.fingerprint(russh::keys::HashAlg::Sha256));
73
74        // `take`: host-key check runs once per connection; move path, no clone.
75        let Some(path) = self.known_hosts_path.take() else {
76            // G-SSH-09: always-trust only in unit tests — product builds reject.
77            #[cfg(test)]
78            {
79                tracing::warn!("known_hosts missing: accepting host key (test mode)");
80                return Ok(true);
81            }
82            #[cfg(not(test))]
83            {
84                stash_host_key_error(
85                    &self.outcome,
86                    SshCliError::InvalidArgument(
87                        "known_hosts_path is required for host-key verification".into(),
88                    ),
89                );
90                tracing::error!("known_hosts path missing; rejecting host key (fail-closed)");
91                return Ok(false);
92            }
93        };
94
95        // G-NET: known_hosts load/save is sync FS + flock — keep it off the
96        // async worker so multi-host fan-out does not stall Tokio threads.
97        let host = self.host.clone();
98        let port = self.port;
99        let replace = self.replace_host_key;
100        let outcome = tokio::task::spawn_blocking(move || {
101            let mut kh = crate::ssh::known_hosts::KnownHosts::load(path)?;
102            crate::ssh::known_hosts::verify_tofu(&mut kh, &host, port, &fingerprint, replace)
103        })
104        .await;
105
106        match outcome {
107            Ok(Ok(true)) => Ok(true),
108            Ok(Ok(false)) => Ok(false),
109            Ok(Err(e)) => {
110                // G-SSH-01: preserve typed HostKeyChanged for the connect caller.
111                stash_host_key_error(&self.outcome, e);
112                tracing::error!("host key rejected");
113                Ok(false)
114            }
115            Err(e) => {
116                stash_host_key_error(
117                    &self.outcome,
118                    SshCliError::ConnectionFailed(format!("known_hosts task failed: {e}")),
119                );
120                tracing::error!(err = %e, "known_hosts task failed");
121                Ok(false)
122            }
123        }
124    }
125
126    async fn auth_banner(
127        &mut self,
128        banner: &str,
129        _session: &mut russh::client::Session,
130    ) -> Result<(), Self::Error> {
131        // G-SSH-14: surface pre-auth banners for diagnostics (truncate; no secrets expected).
132        const MAX: usize = 512;
133        let truncated = if banner.len() > MAX {
134            format!("{}…", &banner[..MAX])
135        } else {
136            banner.to_owned()
137        };
138        tracing::info!(banner = %truncated, "SSH auth banner");
139        Ok(())
140    }
141}