ssh_cli/ssh/
client_handler.rs1#![forbid(unsafe_code)]
4use 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
17pub type HostKeyOutcome = Arc<Mutex<Option<SshCliError>>>;
23
24#[must_use]
26pub fn new_host_key_outcome() -> HostKeyOutcome {
27 Arc::new(Mutex::new(None))
28}
29
30pub 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#[must_use]
39pub fn take_host_key_error(outcome: &HostKeyOutcome) -> Option<SshCliError> {
40 outcome.lock().ok().and_then(|mut g| g.take())
41}
42
43pub type ForwardedSink = tokio::sync::mpsc::Sender<russh::Channel<russh::client::Msg>>;
49
50pub type ForwardedSource = tokio::sync::mpsc::Receiver<russh::Channel<russh::client::Msg>>;
52
53pub 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 #[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 let Some(path) = self.known_hosts_path.take() else {
89 #[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 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 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 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 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 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}