#![forbid(unsafe_code)]
use std::path::PathBuf;
use std::sync::{Arc, Mutex};
use crate::constants::AUTH_BANNER_MAX_CHARS;
use crate::errors::SshCliError;
use crate::ssh::connection::ConnectionConfig;
pub type HostKeyOutcome = Arc<Mutex<Option<SshCliError>>>;
#[must_use]
pub fn new_host_key_outcome() -> HostKeyOutcome {
Arc::new(Mutex::new(None))
}
pub fn stash_host_key_error(outcome: &HostKeyOutcome, err: SshCliError) {
if let Ok(mut g) = outcome.lock() {
*g = Some(err);
}
}
#[must_use]
pub fn take_host_key_error(outcome: &HostKeyOutcome) -> Option<SshCliError> {
outcome.lock().ok().and_then(|mut g| g.take())
}
pub type ForwardedSink = tokio::sync::mpsc::Sender<russh::Channel<russh::client::Msg>>;
pub type ForwardedSource = tokio::sync::mpsc::Receiver<russh::Channel<russh::client::Msg>>;
pub struct ClientHandler {
host: String,
port: u16,
known_hosts_path: Option<PathBuf>,
replace_host_key: bool,
outcome: HostKeyOutcome,
forwarded: ForwardedSink,
}
impl ClientHandler {
#[must_use]
pub fn new(cfg: &ConnectionConfig, outcome: HostKeyOutcome, forwarded: ForwardedSink) -> Self {
Self {
host: cfg.host.as_str().to_owned(),
port: cfg.port.get(),
known_hosts_path: cfg.known_hosts_path.clone(),
replace_host_key: cfg.replace_host_key,
outcome,
forwarded,
}
}
}
impl russh::client::Handler for ClientHandler {
type Error = russh::Error;
async fn check_server_key(
&mut self,
server_key: &russh::keys::ssh_key::PublicKey,
) -> Result<bool, Self::Error> {
let fingerprint = format!("{}", server_key.fingerprint(russh::keys::HashAlg::Sha256));
let Some(path) = self.known_hosts_path.take() else {
#[cfg(test)]
{
tracing::warn!("known_hosts missing: accepting host key (test mode)");
return Ok(true);
}
#[cfg(not(test))]
{
stash_host_key_error(
&self.outcome,
SshCliError::InvalidArgument(
"known_hosts_path is required for host-key verification".into(),
),
);
tracing::error!("known_hosts path missing; rejecting host key (fail-closed)");
return Ok(false);
}
};
let host = self.host.clone();
let port = self.port;
let replace = self.replace_host_key;
let outcome = tokio::task::spawn_blocking(move || {
let mut kh = crate::ssh::known_hosts::KnownHosts::load(path)?;
crate::ssh::known_hosts::verify_tofu(&mut kh, &host, port, &fingerprint, replace)
})
.await;
match outcome {
Ok(Ok(true)) => Ok(true),
Ok(Ok(false)) => Ok(false),
Ok(Err(e)) => {
stash_host_key_error(&self.outcome, e);
tracing::error!("host key rejected");
Ok(false)
}
Err(e) => {
stash_host_key_error(
&self.outcome,
SshCliError::ConnectionFailed(format!("known_hosts task failed: {e}")),
);
tracing::error!(err = %e, "known_hosts task failed");
Ok(false)
}
}
}
async fn auth_banner(
&mut self,
banner: &str,
_session: &mut russh::client::Session,
) -> Result<(), Self::Error> {
let (truncated, was_truncated) =
crate::ssh::session_io::truncate_utf8(banner, AUTH_BANNER_MAX_CHARS);
if was_truncated {
tracing::info!(banner = %truncated, truncated = true, "SSH auth banner");
} else {
tracing::info!(banner = %truncated, "SSH auth banner");
}
Ok(())
}
async fn server_channel_open_forwarded_tcpip(
&mut self,
channel: russh::Channel<russh::client::Msg>,
connected_address: &str,
connected_port: u32,
originator_address: &str,
originator_port: u32,
reply: russh::client::ChannelOpenHandle,
_session: &mut russh::client::Session,
) -> Result<(), Self::Error> {
tracing::debug!(
connected_address,
connected_port,
originator_address,
originator_port,
"server opened forwarded-tcpip channel"
);
match self.forwarded.try_reserve() {
Ok(permit) => {
reply.accept().await;
permit.send(channel);
Ok(())
}
Err(e) => {
tracing::warn!(
err = %e,
connected_address,
connected_port,
"rejecting forwarded-tcpip channel: no active reverse forward or queue full"
);
reply
.reject(russh::ChannelOpenFailure::AdministrativelyProhibited)
.await;
Ok(())
}
}
}
async fn server_channel_open_forwarded_streamlocal(
&mut self,
_channel: russh::Channel<russh::client::Msg>,
socket_path: &str,
reply: russh::client::ChannelOpenHandle,
_session: &mut russh::client::Session,
) -> Result<(), Self::Error> {
tracing::warn!(
socket_path,
"rejecting unsolicited forwarded-streamlocal channel"
);
reply
.reject(russh::ChannelOpenFailure::AdministrativelyProhibited)
.await;
Ok(())
}
}