mod auth;
pub(crate) mod config;
mod jump;
mod known_hosts;
use std::fmt;
use std::io;
use std::path::{Path, PathBuf};
use std::pin::Pin;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex};
use std::task::{Context, Poll};
use std::time::Duration;
use russh::client::{self, DisconnectReason, Handle, Msg};
use russh::keys::{PublicKey, PublicKeyOrCertificate};
use russh::{ChannelMsg, ChannelReadHalf, ChannelStream, ChannelWriteHalf};
use tokio::io::{AsyncWrite, AsyncWriteExt, DuplexStream};
use tokio::sync::oneshot;
use tokio::task::JoinHandle;
use self::auth::AuthPlan;
use self::config::{ConfigSources, HostConfig};
use super::{Pipes, RemoteProcess, Waiter};
use crate::exec::quote;
use crate::exec::ssh::SshError;
const PIPE_BUFFER: usize = 64 * 1024;
const STDERR: u32 = 1;
#[derive(Debug, Clone)]
pub struct DirectTarget {
pub name: String,
pub host: String,
pub port: u16,
pub user: String,
pub key: PathBuf,
pub host_key: PublicKey,
}
pub struct BuiltinTransport {
handle: Handle<Client>,
host: String,
closed: Arc<AtomicBool>,
jumps: Vec<Handle<Client>>,
}
impl fmt::Debug for BuiltinTransport {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("BuiltinTransport")
.field("host", &self.host)
.finish_non_exhaustive()
}
}
#[derive(Debug, Clone)]
enum HostCheck {
Pinned(PublicKey),
KnownHosts {
files: Vec<PathBuf>,
host: String,
port: u16,
alias: Option<String>,
},
}
enum Link {
Tcp,
Through(Box<ChannelStream<Msg>>),
}
struct Plan {
name: String,
host: String,
port: u16,
user: String,
check: HostCheck,
auth: AuthPlan,
connect_timeout: Duration,
alive_interval: Duration,
alive_count: u32,
}
impl BuiltinTransport {
pub async fn connect_direct(target: &DirectTarget) -> Result<Self, SshError> {
Self::connect(
Plan {
name: target.name.clone(),
host: target.host.clone(),
port: target.port,
user: target.user.clone(),
check: HostCheck::Pinned(target.host_key.clone()),
auth: AuthPlan {
agent: None,
files: vec![target.key.clone()],
identities_only: true,
},
connect_timeout: config::DEFAULT_CONNECT_TIMEOUT,
alive_interval: config::DEFAULT_ALIVE_INTERVAL,
alive_count: config::DEFAULT_ALIVE_COUNT,
},
Link::Tcp,
)
.await
}
pub async fn connect_config(
destination: &str,
sources: &ConfigSources,
) -> Result<Self, SshError> {
let auth_sock = std::env::var_os("SSH_AUTH_SOCK")
.filter(|value| !value.is_empty())
.map(PathBuf::from);
let mut hops = jump::chain(destination, sources)?.into_iter().peekable();
let mut jumps = Vec::new();
let mut link = Link::Tcp;
while let Some(hop) = hops.next() {
let plan = config_plan(&hop.name, &hop.host, auth_sock.as_deref());
let session = Self::connect(plan, link).await?;
let Some(next) = hops.peek() else {
jumps.reverse();
return Ok(Self { jumps, ..session });
};
link = Link::Through(Box::new(session.forward(next).await?));
jumps.push(session.handle);
}
Err(SshError::Other(format!(
"no host to connect to for {destination}"
)))
}
async fn forward(&self, next: &jump::Hop) -> Result<ChannelStream<Msg>, SshError> {
let refused = |reason: String| SshError::Connect {
host: next.name.clone(),
reason,
};
let opening = self.handle.channel_open_direct_tcpip(
next.host.host_name.clone(),
u32::from(next.host.port),
"127.0.0.1",
0,
);
let opened = match connect_limit(next.host.connect_timeout) {
Some(limit) => tokio::time::timeout(limit, opening).await.map_err(|_| {
refused(format!(
"{} did not forward to it within {}s",
self.host,
limit.as_secs()
))
})?,
None => opening.await,
};
let channel = opened
.map_err(|error| refused(format!("{} did not forward to it: {error}", self.host)))?;
Ok(channel.into_stream())
}
async fn connect(plan: Plan, link: Link) -> Result<Self, SshError> {
let verdict = Arc::new(Mutex::new(None));
let closed = Arc::new(AtomicBool::new(false));
let handler = Client {
name: plan.name.clone(),
check: plan.check.clone(),
verdict: Arc::clone(&verdict),
closed: Arc::clone(&closed),
};
let config = Arc::new(session_config(&plan));
let connecting = async {
match link {
Link::Tcp => {
client::connect(config, (plan.host.as_str(), plan.port), handler).await
},
Link::Through(stream) => client::connect_stream(config, *stream, handler).await,
}
};
let connected = match connect_limit(plan.connect_timeout) {
Some(limit) => tokio::time::timeout(limit, connecting).await,
None => Ok(connecting.await),
};
let refused = verdict.lock().ok().and_then(|mut slot| slot.take());
let mut handle = match (connected, refused) {
(_, Some(refused)) => return Err(refused),
(Ok(Ok(handle)), None) => handle,
(Ok(Err(error)), None) => {
return Err(SshError::Connect {
host: plan.name,
reason: error.to_string(),
});
},
(Err(_), None) => {
return Err(SshError::Connect {
host: plan.name,
reason: format!("timed out after {}s", plan.connect_timeout.as_secs()),
});
},
};
auth::authenticate(&mut handle, &plan.user, &plan.auth)
.await
.map_err(|reason| SshError::Auth {
user: plan.user.clone(),
host: plan.name.clone(),
reason,
})?;
Ok(Self {
handle,
host: plan.name,
closed,
jumps: Vec::new(),
})
}
pub async fn exec(&self, command: &str, pipes: Pipes) -> Result<RemoteProcess, SshError> {
let channel = self
.handle
.channel_open_session()
.await
.map_err(|error| self.lost(&error))?;
channel
.exec(true, format!("sh -c {}", quote(command)))
.await
.map_err(|error| self.lost(&error))?;
let (mut read, write) = channel.split();
let mut early = Vec::new();
loop {
match read.wait().await {
Some(ChannelMsg::Success) => break,
Some(ChannelMsg::Failure) => {
return Err(SshError::Other(format!(
"{} refused to run the command",
self.host
)));
},
Some(other) => early.push(other),
None => return Err(self.ended()),
}
}
let (stdin, eof) = if pipes.stdin {
let (signal, eof) = oneshot::channel();
let stdin = ChannelStdin {
writer: Box::pin(write.make_writer()),
eof: Some(signal),
};
(
Some(Box::new(stdin) as Box<dyn AsyncWrite + Send + Unpin>),
Some(eof),
)
} else {
write.eof().await.map_err(|error| self.lost(&error))?;
(None, None)
};
let (stdout, out) = pipe(pipes.stdout);
let (stderr, err) = pipe(pipes.stderr);
let pump = Pump {
read,
write,
out,
err,
eof,
closed: Arc::clone(&self.closed),
};
let task = tokio::spawn(pump.run(early));
Ok(RemoteProcess {
stdin,
stdout: stdout
.map(|reader| Box::new(reader) as Box<dyn tokio::io::AsyncRead + Send + Unpin>),
stderr: stderr
.map(|reader| Box::new(reader) as Box<dyn tokio::io::AsyncRead + Send + Unpin>),
waiter: Waiter::Builtin(BuiltinWaiter { task }),
})
}
fn lost(&self, error: &russh::Error) -> SshError {
if self.gone() {
SshError::Disconnected
} else {
SshError::Other(format!("{}: {error}", self.host))
}
}
fn gone(&self) -> bool {
self.handle.is_closed()
|| self.closed.load(Ordering::SeqCst)
|| self.jumps.iter().any(Handle::is_closed)
}
fn ended(&self) -> SshError {
if self.gone() {
SshError::Disconnected
} else {
SshError::Other(format!("{} closed the channel of the command", self.host))
}
}
}
fn session_config(plan: &Plan) -> client::Config {
client::Config {
keepalive_interval: (!plan.alive_interval.is_zero()).then_some(plan.alive_interval),
keepalive_max: usize::try_from(plan.alive_count).unwrap_or(usize::MAX),
..client::Config::default()
}
}
fn connect_limit(timeout: Duration) -> Option<Duration> {
(!timeout.is_zero()).then_some(timeout)
}
fn config_plan(destination: &str, host: &HostConfig, auth_sock: Option<&Path>) -> Plan {
Plan {
name: destination.to_string(),
host: host.host_name.clone(),
port: host.port,
user: host.user.clone(),
check: HostCheck::KnownHosts {
files: host.known_hosts.clone(),
host: host.host_name.clone(),
port: host.port,
alias: host.host_key_alias.clone(),
},
auth: auth::plan(host, auth_sock),
connect_timeout: host.connect_timeout,
alive_interval: host.alive_interval,
alive_count: host.alive_count,
}
}
fn server_key_verdict(
check: &HostCheck,
name: &str,
key: &PublicKeyOrCertificate,
) -> Result<(), SshError> {
let PublicKeyOrCertificate::PublicKey { key, .. } = key else {
return Err(SshError::HostKey {
host: name.to_string(),
reason: "host certificates are not supported by the built-in SSH client".into(),
});
};
match check {
HostCheck::Pinned(pinned) if pinned.key_data() == key.key_data() => Ok(()),
HostCheck::Pinned(_) => Err(SshError::HostKey {
host: name.to_string(),
reason: "the host key is not the one pinned for this run".into(),
}),
HostCheck::KnownHosts {
files,
host,
port,
alias,
} => known_hosts::check(files, host, *port, alias.as_deref(), key),
}
}
struct Client {
name: String,
check: HostCheck,
verdict: Arc<Mutex<Option<SshError>>>,
closed: Arc<AtomicBool>,
}
impl client::Handler for Client {
type Error = russh::Error;
fn check_server_key(
&mut self,
server_public_key: &PublicKeyOrCertificate,
) -> impl Future<Output = Result<bool, Self::Error>> + Send {
let accepted = match server_key_verdict(&self.check, &self.name, server_public_key) {
Ok(()) => true,
Err(refused) => {
if let Ok(mut slot) = self.verdict.lock() {
*slot = Some(refused);
}
false
},
};
std::future::ready(Ok(accepted))
}
fn disconnected(
&mut self,
reason: DisconnectReason<Self::Error>,
) -> impl Future<Output = Result<(), Self::Error>> + Send {
self.closed.store(true, Ordering::SeqCst);
std::future::ready(match reason {
DisconnectReason::ReceivedDisconnect(_) => Ok(()),
DisconnectReason::Error(error) => Err(error),
})
}
}
fn pipe(piped: bool) -> (Option<DuplexStream>, Option<DuplexStream>) {
if piped {
let (reader, writer) = tokio::io::duplex(PIPE_BUFFER);
(Some(reader), Some(writer))
} else {
(None, None)
}
}
struct ChannelStdin {
writer: Pin<Box<dyn AsyncWrite + Send>>,
eof: Option<oneshot::Sender<()>>,
}
impl AsyncWrite for ChannelStdin {
fn poll_write(
mut self: Pin<&mut Self>,
cx: &mut Context<'_>,
buf: &[u8],
) -> Poll<io::Result<usize>> {
self.writer.as_mut().poll_write(cx, buf)
}
fn poll_flush(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<io::Result<()>> {
self.writer.as_mut().poll_flush(cx)
}
fn poll_shutdown(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<io::Result<()>> {
let done = self.writer.as_mut().poll_shutdown(cx);
if matches!(done, Poll::Ready(Ok(()))) {
self.eof = None;
}
done
}
}
impl Drop for ChannelStdin {
fn drop(&mut self) {
if let Some(eof) = self.eof.take() {
let _ = eof.send(());
}
}
}
struct Pump {
read: ChannelReadHalf,
write: ChannelWriteHalf<Msg>,
out: Option<DuplexStream>,
err: Option<DuplexStream>,
eof: Option<oneshot::Receiver<()>>,
closed: Arc<AtomicBool>,
}
impl Pump {
async fn run(mut self, early: Vec<ChannelMsg>) -> Result<i32, SshError> {
let mut outcome = Outcome::default();
for message in early {
if self.handle(message, &mut outcome).await {
return outcome.result(&self.closed);
}
}
loop {
let message = match self.eof.as_mut() {
Some(eof) => tokio::select! {
message = self.read.wait() => message,
sent = eof => {
self.eof = None;
if sent.is_ok() {
drop(self.write.eof().await);
}
continue;
},
},
None => self.read.wait().await,
};
let Some(message) = message else {
break;
};
if self.handle(message, &mut outcome).await {
break;
}
}
self.out = None;
self.err = None;
outcome.result(&self.closed)
}
async fn handle(&mut self, message: ChannelMsg, outcome: &mut Outcome) -> bool {
match message {
ChannelMsg::Data { data } => forward(&mut self.out, &data).await,
ChannelMsg::ExtendedData { data, ext } if ext == STDERR => {
forward(&mut self.err, &data).await;
},
ChannelMsg::ExitStatus { exit_status } => {
outcome.status = Some(i32::try_from(exit_status).unwrap_or(i32::MAX));
},
ChannelMsg::ExitSignal { .. } => outcome.signalled = true,
ChannelMsg::Eof => {
self.out = None;
self.err = None;
},
ChannelMsg::Close => {
outcome.channel_closed = true;
return true;
},
_ => {},
}
false
}
}
async fn forward(pipe: &mut Option<DuplexStream>, data: &[u8]) {
if let Some(writer) = pipe
&& writer.write_all(data).await.is_err()
{
*pipe = None;
}
}
#[derive(Default)]
struct Outcome {
status: Option<i32>,
signalled: bool,
channel_closed: bool,
}
impl Outcome {
fn result(&self, closed: &AtomicBool) -> Result<i32, SshError> {
match self.status {
Some(code) if !self.signalled => Ok(code),
_ if closed.load(Ordering::SeqCst) || !self.channel_closed => {
Err(SshError::Disconnected)
},
_ => Err(SshError::Terminated),
}
}
}
pub(crate) struct BuiltinWaiter {
task: JoinHandle<Result<i32, SshError>>,
}
impl BuiltinWaiter {
pub(crate) async fn wait(self) -> Result<i32, SshError> {
self.task
.await
.map_err(|_| SshError::Other("the reader of a remote command stopped".into()))?
}
}
#[cfg(test)]
mod tests {
use russh::keys::{Algorithm, PrivateKey};
use super::*;
type TestResult = Result<(), Box<dyn std::error::Error>>;
fn public() -> Result<PublicKey, Box<dyn std::error::Error>> {
Ok(PrivateKey::random(&mut rand::rng(), Algorithm::Ed25519)?
.public_key()
.clone())
}
fn offered(key: &PublicKey) -> PublicKeyOrCertificate {
PublicKeyOrCertificate::PublicKey {
key: key.clone(),
hash_alg: None,
}
}
#[test]
fn a_pinned_key_accepts_only_itself() -> TestResult {
let pinned = public()?;
let check = HostCheck::Pinned(pinned.clone());
assert!(server_key_verdict(&check, "pod", &offered(&pinned)).is_ok());
let refused = server_key_verdict(&check, "pod", &offered(&public()?));
let Err(error) = refused else {
return Err("another key was accepted".into());
};
assert!(matches!(error, SshError::HostKey { .. }), "{error:?}");
assert!(
error
.to_string()
.starts_with("host key verification failed for pod: ")
);
Ok(())
}
#[test]
fn a_configured_host_checks_known_hosts() -> TestResult {
let dir = tempfile::tempdir()?;
let key = public()?;
let file = dir.path().join("known_hosts");
std::fs::write(&file, format!("[h.example]:2222 {}\n", key.to_openssh()?))?;
let check = |host: &str| HostCheck::KnownHosts {
files: vec![file.clone()],
host: host.to_string(),
port: 2222,
alias: None,
};
assert!(server_key_verdict(&check("h.example"), "h", &offered(&key)).is_ok());
let unknown = server_key_verdict(&check("other.example"), "o", &offered(&key));
assert!(
matches!(unknown, Err(SshError::HostKey { .. })),
"{unknown:?}"
);
Ok(())
}
#[test]
fn the_outcome_maps_to_status_terminated_or_disconnected() {
let open = AtomicBool::new(false);
let gone = AtomicBool::new(true);
let exited = Outcome {
status: Some(3),
signalled: false,
channel_closed: true,
};
assert!(matches!(exited.result(&open), Ok(3)));
assert!(matches!(exited.result(&gone), Ok(3)));
let silent = Outcome {
channel_closed: true,
..Outcome::default()
};
assert!(matches!(silent.result(&open), Err(SshError::Terminated)));
assert!(matches!(silent.result(&gone), Err(SshError::Disconnected)));
let killed = Outcome {
status: None,
signalled: true,
channel_closed: true,
};
assert!(matches!(killed.result(&open), Err(SshError::Terminated)));
let cut = Outcome::default();
assert!(matches!(cut.result(&open), Err(SshError::Disconnected)));
}
fn timed(alive: Duration, connect: Duration) -> Plan {
Plan {
name: "h".into(),
host: "h".into(),
port: 22,
user: "u".into(),
check: HostCheck::KnownHosts {
files: Vec::new(),
host: "h".into(),
port: 22,
alias: None,
},
auth: AuthPlan {
agent: None,
files: Vec::new(),
identities_only: false,
},
connect_timeout: connect,
alive_interval: alive,
alive_count: 2,
}
}
#[test]
fn a_zero_alive_interval_disables_keepalives() {
let off = session_config(&timed(Duration::ZERO, Duration::from_secs(5)));
assert_eq!(off.keepalive_interval, None);
let on = session_config(&timed(Duration::from_secs(9), Duration::from_secs(5)));
assert_eq!(on.keepalive_interval, Some(Duration::from_secs(9)));
assert_eq!(on.keepalive_max, 2);
}
#[test]
fn a_zero_connect_timeout_means_no_limit() {
assert_eq!(connect_limit(Duration::ZERO), None);
assert_eq!(
connect_limit(Duration::from_secs(7)),
Some(Duration::from_secs(7))
);
}
#[test]
fn a_resolved_host_becomes_its_plan() {
let host = HostConfig {
alias: "gpu".into(),
host_name: "10.0.0.5".into(),
user: "me".into(),
port: 2200,
identity_files: vec!["/k".into()],
identities_only: true,
identity_agent: config::AgentChoice::Env,
known_hosts: vec!["/kh".into()],
host_key_alias: Some("gpu-alias".into()),
proxy_jump: Vec::new(),
connect_timeout: Duration::from_secs(7),
alive_interval: Duration::from_secs(9),
alive_count: 2,
};
let plan = config_plan("me@gpu", &host, Some(Path::new("/sock")));
assert_eq!(plan.name, "me@gpu");
assert_eq!((plan.host.as_str(), plan.port), ("10.0.0.5", 2200));
assert_eq!(plan.user, "me");
assert!(matches!(
&plan.check,
HostCheck::KnownHosts { alias: Some(alias), port: 2200, .. } if alias == "gpu-alias"
));
assert_eq!(plan.auth.agent.as_deref(), Some(Path::new("/sock")));
assert!(plan.auth.identities_only);
assert_eq!(plan.connect_timeout, Duration::from_secs(7));
assert_eq!(plan.alive_interval, Duration::from_secs(9));
assert_eq!(plan.alive_count, 2);
}
}