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::constants::AUTH_BANNER_MAX_CHARS;
14use crate::errors::SshCliError;
15use crate::ssh::connection::ConnectionConfig;
16
17/// Shared slot for host-key / TOFU failures observed inside the russh handler.
18///
19/// After `connect_stream` returns `Err`, the connect path must `take()` this
20/// value so agents receive typed [`SshCliError::HostKeyChanged`] (exit EX_NOPERM)
21/// instead of a generic handshake failure.
22pub type HostKeyOutcome = Arc<Mutex<Option<SshCliError>>>;
23
24/// Create an empty shared outcome slot.
25#[must_use]
26pub fn new_host_key_outcome() -> HostKeyOutcome {
27    Arc::new(Mutex::new(None))
28}
29
30/// Store a product error for the connect caller (best-effort if lock is poisoned).
31pub fn stash_host_key_error(outcome: &HostKeyOutcome, err: SshCliError) {
32    if let Ok(mut g) = outcome.lock() {
33        *g = Some(err);
34    }
35}
36
37/// Take a stashed host-key error if present.
38#[must_use]
39pub fn take_host_key_error(outcome: &HostKeyOutcome) -> Option<SshCliError> {
40    outcome.lock().ok().and_then(|mut g| g.take())
41}
42
43/// Sink for channels the **server** opens against an active reverse forward.
44///
45/// G-TUN-R01: `forwarded-tcpip` channels are inbound — russh delivers them to the
46/// handler, which lives inside the session task and cannot be reached from the
47/// tunnel loop. This bounded queue is the bridge between the two.
48pub type ForwardedSink = tokio::sync::mpsc::Sender<russh::Channel<russh::client::Msg>>;
49
50/// Receiving half of [`ForwardedSink`], held by the client.
51pub type ForwardedSource = tokio::sync::mpsc::Receiver<russh::Channel<russh::client::Msg>>;
52
53/// russh handler with TOFU known_hosts (or test-only always-trust when path is absent).
54pub struct ClientHandler {
55    host: String,
56    port: u16,
57    known_hosts_path: Option<PathBuf>,
58    replace_host_key: bool,
59    outcome: HostKeyOutcome,
60    forwarded: ForwardedSink,
61}
62
63impl ClientHandler {
64    /// Build a handler from connection config, outcome slot and inbound-channel sink.
65    #[must_use]
66    pub fn new(cfg: &ConnectionConfig, outcome: HostKeyOutcome, forwarded: ForwardedSink) -> Self {
67        Self {
68            host: cfg.host.as_str().to_owned(),
69            port: cfg.port.get(),
70            known_hosts_path: cfg.known_hosts_path.clone(),
71            replace_host_key: cfg.replace_host_key,
72            outcome,
73            forwarded,
74        }
75    }
76}
77
78impl russh::client::Handler for ClientHandler {
79    type Error = russh::Error;
80
81    async fn check_server_key(
82        &mut self,
83        server_key: &russh::keys::ssh_key::PublicKey,
84    ) -> Result<bool, Self::Error> {
85        let fingerprint = format!("{}", server_key.fingerprint(russh::keys::HashAlg::Sha256));
86
87        // `take`: host-key check runs once per connection; move path, no clone.
88        let Some(path) = self.known_hosts_path.take() else {
89            // G-SSH-09: always-trust only in unit tests — product builds reject.
90            #[cfg(test)]
91            {
92                tracing::warn!("known_hosts missing: accepting host key (test mode)");
93                return Ok(true);
94            }
95            #[cfg(not(test))]
96            {
97                stash_host_key_error(
98                    &self.outcome,
99                    SshCliError::InvalidArgument(
100                        "known_hosts_path is required for host-key verification".into(),
101                    ),
102                );
103                tracing::error!("known_hosts path missing; rejecting host key (fail-closed)");
104                return Ok(false);
105            }
106        };
107
108        // G-NET: known_hosts load/save is sync FS + flock — keep it off the
109        // async worker so multi-host fan-out does not stall Tokio threads.
110        let host = self.host.clone();
111        let port = self.port;
112        let replace = self.replace_host_key;
113        let outcome = tokio::task::spawn_blocking(move || {
114            let mut kh = crate::ssh::known_hosts::KnownHosts::load(path)?;
115            crate::ssh::known_hosts::verify_tofu(&mut kh, &host, port, &fingerprint, replace)
116        })
117        .await;
118
119        match outcome {
120            Ok(Ok(true)) => Ok(true),
121            Ok(Ok(false)) => Ok(false),
122            Ok(Err(e)) => {
123                // G-SSH-01: preserve typed HostKeyChanged for the connect caller.
124                stash_host_key_error(&self.outcome, e);
125                tracing::error!("host key rejected");
126                Ok(false)
127            }
128            Err(e) => {
129                stash_host_key_error(
130                    &self.outcome,
131                    SshCliError::ConnectionFailed(format!("known_hosts task failed: {e}")),
132                );
133                tracing::error!(err = %e, "known_hosts task failed");
134                Ok(false)
135            }
136        }
137    }
138
139    async fn auth_banner(
140        &mut self,
141        banner: &str,
142        _session: &mut russh::client::Session,
143    ) -> Result<(), Self::Error> {
144        // G-SSH-14: surface pre-auth banners for diagnostics (truncate; no secrets expected).
145        //
146        // A1 (remote pre-auth DoS): `banner` is server-controlled and arrives *before*
147        // authentication, so it is hostile input by definition. Slicing it by byte index
148        // panics whenever the cut lands inside a multi-byte character, and the release
149        // profile sets `panic = "abort"` — no unwind, the whole process dies, taking a
150        // multi-host fan-out down with it. Truncate on character boundaries instead.
151        let (truncated, was_truncated) =
152            crate::ssh::session_io::truncate_utf8(banner, AUTH_BANNER_MAX_CHARS);
153        if was_truncated {
154            tracing::info!(banner = %truncated, truncated = true, "SSH auth banner");
155        } else {
156            tracing::info!(banner = %truncated, "SSH auth banner");
157        }
158        Ok(())
159    }
160
161    /// Accepts a `forwarded-tcpip` channel opened by the server (G-TUN-R01).
162    ///
163    /// Only channels that fit in the bounded queue are accepted. A full queue means
164    /// nobody is draining the reverse forward, so accepting would buffer indefinitely
165    /// on the peer's schedule; rejecting applies backpressure over the wire instead.
166    ///
167    /// The default russh implementation accepts unconditionally and drops the
168    /// channel, which for a client that never requested a forward means silently
169    /// letting a server open channels into the process.
170    async fn server_channel_open_forwarded_tcpip(
171        &mut self,
172        channel: russh::Channel<russh::client::Msg>,
173        connected_address: &str,
174        connected_port: u32,
175        originator_address: &str,
176        originator_port: u32,
177        reply: russh::client::ChannelOpenHandle,
178        _session: &mut russh::client::Session,
179    ) -> Result<(), Self::Error> {
180        tracing::debug!(
181            connected_address,
182            connected_port,
183            originator_address,
184            originator_port,
185            "server opened forwarded-tcpip channel"
186        );
187        match self.forwarded.try_reserve() {
188            Ok(permit) => {
189                reply.accept().await;
190                permit.send(channel);
191                Ok(())
192            }
193            Err(e) => {
194                tracing::warn!(
195                    err = %e,
196                    connected_address,
197                    connected_port,
198                    "rejecting forwarded-tcpip channel: no active reverse forward or queue full"
199                );
200                reply
201                    .reject(russh::ChannelOpenFailure::AdministrativelyProhibited)
202                    .await;
203                Ok(())
204            }
205        }
206    }
207
208    /// Rejects `forwarded-streamlocal` channels.
209    ///
210    /// This CLI only ever requests *outbound* streamlocal channels
211    /// (`--remote-socket`), never a remote Unix-socket listener, so an inbound one
212    /// was not asked for. Accepting and dropping it — the russh default — would make
213    /// the refusal invisible to both sides.
214    async fn server_channel_open_forwarded_streamlocal(
215        &mut self,
216        _channel: russh::Channel<russh::client::Msg>,
217        socket_path: &str,
218        reply: russh::client::ChannelOpenHandle,
219        _session: &mut russh::client::Session,
220    ) -> Result<(), Self::Error> {
221        tracing::warn!(
222            socket_path,
223            "rejecting unsolicited forwarded-streamlocal channel"
224        );
225        reply
226            .reject(russh::ChannelOpenFailure::AdministrativelyProhibited)
227            .await;
228        Ok(())
229    }
230}