use std::path::{Path, PathBuf};
use std::time::Duration;
use crate::error::{Error, Result};
const DEFAULT_CONNECT_TIMEOUT: Duration = Duration::from_secs(15);
#[derive(Clone)]
pub struct SshConfig {
pub host: String,
pub port: u16,
pub user: String,
pub auth: SshAuth,
pub host_key: HostKeyVerification,
pub connect_timeout: Duration,
}
impl SshConfig {
pub fn new(
host: impl Into<String>,
user: impl Into<String>,
auth: SshAuth,
host_key: HostKeyVerification,
) -> Self {
Self {
host: host.into(),
port: 22,
user: user.into(),
auth,
host_key,
connect_timeout: DEFAULT_CONNECT_TIMEOUT,
}
}
}
#[derive(Clone)]
pub enum SshAuth {
Password(String),
Key {
path: PathBuf,
passphrase: Option<String>,
},
}
impl std::fmt::Debug for SshAuth {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
SshAuth::Password(_) => f.write_str("Password(***)"),
SshAuth::Key { path, .. } => write!(f, "Key {{ path: {path:?}, passphrase: *** }}"),
}
}
}
impl std::fmt::Debug for SshConfig {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("SshConfig")
.field("host", &self.host)
.field("port", &self.port)
.field("user", &self.user)
.field("auth", &self.auth)
.field("host_key", &self.host_key)
.field("connect_timeout", &self.connect_timeout)
.finish()
}
}
#[derive(Debug, Clone)]
pub enum HostKeyVerification {
Pinned(String),
KnownHosts(PathBuf),
AcceptAny,
}
#[allow(dead_code)]
pub(crate) fn verify_fingerprint(
policy: &HostKeyVerification,
host: &str,
port: u16,
fingerprint: &str,
) -> Result<bool> {
match policy {
HostKeyVerification::AcceptAny => Ok(true),
HostKeyVerification::Pinned(expected) => Ok(expected == fingerprint),
HostKeyVerification::KnownHosts(path) => verify_known_hosts(path, host, port, fingerprint),
}
}
fn known_hosts_key(host: &str, port: u16) -> String {
if port == 22 {
host.to_string()
} else {
format!("[{host}]:{port}")
}
}
#[allow(dead_code)]
fn verify_known_hosts(path: &Path, host: &str, port: u16, fingerprint: &str) -> Result<bool> {
let key = known_hosts_key(host, port);
let content = match std::fs::read(path) {
Ok(b) => String::from_utf8_lossy(&b).into_owned(),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => String::new(),
Err(e) => return Err(e.into()),
};
let mut foreign_format = false;
for line in content.lines() {
let mut it = line.split_whitespace();
let (Some(h), Some(fp)) = (it.next(), it.next()) else {
continue;
};
if !fp.starts_with("SHA256:") {
foreign_format = true;
continue;
}
if h == key {
return Ok(fp == fingerprint);
}
}
if foreign_format {
return Err(Error::Transport(format!(
"known_hosts file {} is in OpenSSH format; point EXECKIT_MCP_KNOWN_HOSTS at an \
execkit-managed file (default ~/.execkit/known_hosts) or pin 'fingerprint'",
path.display()
)));
}
if let Some(parent) = path.parent() {
if !parent.as_os_str().is_empty() {
if !parent.exists() {
std::fs::create_dir_all(parent)?;
#[cfg(unix)]
chmod_0700(parent)?;
} else if is_default_known_hosts_dir(parent) {
#[cfg(unix)]
chmod_0700(parent)?;
}
}
}
use std::io::Write;
let mut f = std::fs::OpenOptions::new()
.create(true)
.append(true)
.open(path)?;
f.write_all(format!("{key} {fingerprint}\n").as_bytes())?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600))?;
}
Ok(true)
}
fn is_default_known_hosts_dir(dir: &Path) -> bool {
dir.file_name() == Some(std::ffi::OsStr::new(".execkit"))
}
#[cfg(unix)]
fn chmod_0700(dir: &Path) -> Result<()> {
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(dir, std::fs::Permissions::from_mode(0o700))?;
Ok(())
}
#[cfg(feature = "ssh")]
mod imp {
use std::sync::mpsc as std_mpsc;
use std::sync::{Arc, Mutex};
use std::thread::JoinHandle;
use std::time::Duration;
use russh::client;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::sync::mpsc as tokio_mpsc;
use super::{verify_fingerprint, HostKeyVerification, SshAuth, SshConfig};
use crate::error::{Error, Result};
use crate::transport::Transport;
const CHANNEL_CAP: usize = 64;
pub struct SshTransport {
write_tx: Option<tokio_mpsc::Sender<Vec<u8>>>,
read_rx: Option<std_mpsc::Receiver<Vec<u8>>>,
thread: Option<JoinHandle<()>>,
}
impl SshTransport {
pub fn connect(cfg: SshConfig) -> Result<Self> {
let (write_tx, write_rx) = tokio_mpsc::channel::<Vec<u8>>(CHANNEL_CAP);
let (read_tx, read_rx) = std_mpsc::sync_channel::<Vec<u8>>(CHANNEL_CAP);
let (ready_tx, ready_rx) = std_mpsc::channel::<Result<()>>();
let thread = std::thread::spawn(move || {
let rt = match tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
{
Ok(rt) => rt,
Err(e) => {
let _ = ready_tx.send(Err(Error::Transport(format!("runtime: {e}"))));
return;
}
};
rt.block_on(io_loop(cfg, write_rx, read_tx, ready_tx));
});
match ready_rx.recv() {
Ok(Ok(())) => Ok(SshTransport {
write_tx: Some(write_tx),
read_rx: Some(read_rx),
thread: Some(thread),
}),
Ok(Err(e)) => {
let _ = thread.join();
Err(e)
}
Err(_) => Err(Error::Transport("ssh thread died during connect".into())),
}
}
}
impl Transport for SshTransport {
fn write_all(&mut self, bytes: &[u8]) -> Result<()> {
let tx = self
.write_tx
.as_ref()
.ok_or_else(|| Error::Transport("ssh session closed".into()))?;
tx.blocking_send(bytes.to_vec())
.map_err(|_| Error::Transport("ssh session closed".into()))
}
fn recv_timeout(&self, dur: Duration) -> Option<Vec<u8>> {
self.read_rx.as_ref()?.recv_timeout(dur).ok()
}
}
impl Drop for SshTransport {
fn drop(&mut self) {
self.write_tx = None;
self.read_rx = None;
if let Some(t) = self.thread.take() {
let _ = t.join();
}
}
}
struct Handler {
policy: HostKeyVerification,
host: String,
port: u16,
verify_err: Arc<Mutex<Option<Error>>>,
}
impl client::Handler for Handler {
type Error = russh::Error;
async fn check_server_key(
&mut self,
server_public_key: &russh::keys::ssh_key::PublicKey,
) -> std::result::Result<bool, Self::Error> {
let fp = server_public_key
.fingerprint(russh::keys::ssh_key::HashAlg::Sha256)
.to_string();
match verify_fingerprint(&self.policy, &self.host, self.port, &fp) {
Ok(accept) => Ok(accept),
Err(e) => {
*self.verify_err.lock().unwrap_or_else(|p| p.into_inner()) = Some(e);
Ok(false)
}
}
}
}
async fn connect_and_auth(cfg: &SshConfig) -> Result<client::Handle<Handler>> {
let config = Arc::new(client::Config::default());
let verify_err = Arc::new(Mutex::new(None));
let handler = Handler {
policy: cfg.host_key.clone(),
host: cfg.host.clone(),
port: cfg.port,
verify_err: verify_err.clone(),
};
let mut handle = client::connect(config, (cfg.host.as_str(), cfg.port), handler)
.await
.map_err(|e| {
verify_err
.lock()
.unwrap_or_else(|p| p.into_inner())
.take()
.unwrap_or_else(|| Error::Transport(format!("ssh connect: {e}")))
})?;
let result = match &cfg.auth {
SshAuth::Password(p) => handle
.authenticate_password(cfg.user.clone(), p.clone())
.await
.map_err(|e| Error::Transport(format!("ssh auth: {e}")))?,
SshAuth::Key { path, passphrase } => {
let key = russh::keys::load_secret_key(path, passphrase.as_deref())
.map_err(|e| Error::Transport(format!("load key: {e}")))?;
let hash = handle
.best_supported_rsa_hash()
.await
.ok()
.flatten()
.flatten();
let key = russh::keys::PrivateKeyWithHashAlg::new(Arc::new(key), hash);
handle
.authenticate_publickey(cfg.user.clone(), key)
.await
.map_err(|e| Error::Transport(format!("ssh auth: {e}")))?
}
};
if !result.success() {
return Err(Error::Transport("ssh authentication failed".into()));
}
Ok(handle)
}
async fn establish(
cfg: &SshConfig,
) -> Result<(client::Handle<Handler>, russh::Channel<client::Msg>)> {
let handle = match tokio::time::timeout(cfg.connect_timeout, connect_and_auth(cfg)).await {
Ok(Ok(handle)) => handle,
Ok(Err(e)) => return Err(e),
Err(_) => {
return Err(Error::Transport(format!(
"ssh: connect to {}:{} timed out after {}s",
cfg.host,
cfg.port,
cfg.connect_timeout.as_secs()
)))
}
};
let channel = handle
.channel_open_session()
.await
.map_err(|e| Error::Transport(format!("open channel: {e}")))?;
channel
.request_pty(false, "xterm-256color", 120, 40, 0, 0, &[])
.await
.map_err(|e| Error::Transport(format!("request pty: {e}")))?;
channel
.exec(false, "/bin/sh")
.await
.map_err(|e| Error::Transport(format!("start shell: {e}")))?;
Ok((handle, channel))
}
async fn io_loop(
cfg: SshConfig,
mut write_rx: tokio_mpsc::Receiver<Vec<u8>>,
read_tx: std_mpsc::SyncSender<Vec<u8>>,
ready_tx: std_mpsc::Sender<Result<()>>,
) {
let (handle, channel) = match establish(&cfg).await {
Ok(v) => v,
Err(e) => {
let _ = ready_tx.send(Err(e));
return;
}
};
let _ = ready_tx.send(Ok(()));
let _keep = handle;
let stream = channel.into_stream(); let (mut rd, mut wr) = tokio::io::split(stream);
let mut buf = [0u8; 8192];
loop {
tokio::select! {
r = rd.read(&mut buf) => match r {
Ok(0) | Err(_) => break,
Ok(n) => if read_tx.send(buf[..n].to_vec()).is_err() { break; },
},
w = write_rx.recv() => match w {
Some(bytes) => {
if wr.write_all(&bytes).await.is_err() { break; }
let _ = wr.flush().await;
}
None => break, },
}
}
}
#[cfg(test)]
mod tests {
use std::sync::{Arc, Mutex};
use russh::client::Handler as _;
use russh::keys::ssh_key::private::Ed25519Keypair;
use russh::keys::PrivateKey;
use super::{Handler, SshTransport};
use crate::transport::ssh::{HostKeyVerification, SshAuth, SshConfig};
fn test_key() -> PrivateKey {
PrivateKey::from(Ed25519Keypair::from_seed(&[7u8; 32]))
}
fn openssh_known_hosts(tag: &str) -> std::path::PathBuf {
let path =
std::env::temp_dir().join(format!("execkit_kh_imp_{tag}_{}", std::process::id()));
std::fs::write(&path, "otherhost ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAA\n").unwrap();
path
}
#[test]
fn handler_keeps_verification_error() {
let path = openssh_known_hosts("handler");
let slot = Arc::new(Mutex::new(None));
let mut h = Handler {
policy: HostKeyVerification::KnownHosts(path.clone()),
host: "newhost".into(),
port: 22,
verify_err: slot.clone(),
};
let rt = tokio::runtime::Builder::new_current_thread()
.build()
.unwrap();
let accepted = rt
.block_on(h.check_server_key(test_key().public_key()))
.unwrap();
assert!(!accepted, "a verification error must fail closed");
let err = slot.lock().unwrap().take().expect("error captured");
assert!(err.to_string().contains("OpenSSH format"), "{err}");
let _ = std::fs::remove_file(&path);
}
struct NoAuthServer;
impl russh::server::Handler for NoAuthServer {
type Error = russh::Error;
}
#[test]
fn connect_returns_known_hosts_error_not_generic_reject() {
let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
let port = listener.local_addr().unwrap().port();
listener.set_nonblocking(true).unwrap();
std::thread::spawn(move || {
let rt = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.unwrap();
rt.block_on(async move {
let listener = tokio::net::TcpListener::from_std(listener).unwrap();
let config = Arc::new(russh::server::Config {
keys: vec![test_key()],
..Default::default()
});
if let Ok((sock, _)) = listener.accept().await {
if let Ok(running) =
russh::server::run_stream(config, sock, NoAuthServer).await
{
let _ = running.await;
}
}
});
});
let path = openssh_known_hosts("connect");
let mut cfg = SshConfig::new(
"127.0.0.1",
"nobody",
SshAuth::Password("x".into()),
HostKeyVerification::KnownHosts(path.clone()),
);
cfg.port = port;
cfg.connect_timeout = std::time::Duration::from_secs(10);
let msg = match SshTransport::connect(cfg) {
Ok(_) => panic!("connect must fail"),
Err(e) => e.to_string(),
};
assert!(msg.contains("OpenSSH format"), "got: {msg}");
let _ = std::fs::remove_file(&path);
}
}
}
#[cfg(feature = "ssh")]
pub use imp::SshTransport;
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn pinned_matches_only_exact() {
let p = HostKeyVerification::Pinned("SHA256:abc".into());
assert!(verify_fingerprint(&p, "h", 22, "SHA256:abc").unwrap());
assert!(!verify_fingerprint(&p, "h", 22, "SHA256:evil").unwrap());
}
#[test]
fn known_hosts_tofu_then_pins_and_detects_change() {
let dir = std::env::temp_dir();
let path = dir.join(format!("execkit_kh_test_{}", std::process::id()));
let _ = std::fs::remove_file(&path);
let p = HostKeyVerification::KnownHosts(path.clone());
assert!(verify_fingerprint(&p, "prod-1", 22, "SHA256:good").unwrap());
assert!(verify_fingerprint(&p, "prod-1", 22, "SHA256:good").unwrap());
assert!(!verify_fingerprint(&p, "prod-1", 22, "SHA256:evil").unwrap());
assert!(verify_fingerprint(&p, "prod-2", 22, "SHA256:other").unwrap());
let _ = std::fs::remove_file(&path);
}
#[test]
fn auth_debug_redacts_secrets() {
let a = SshAuth::Password("hunter2".into());
assert!(!format!("{a:?}").contains("hunter2"));
}
#[test]
fn known_hosts_corrupt_file_fails_closed() {
let dir = std::env::temp_dir();
let path = dir.join(format!("execkit_kh_corrupt_{}", std::process::id()));
let mut bytes = b"prod-1 SHA256:GOODKEY\n".to_vec();
bytes.extend_from_slice(b"\xff\xfe bad\n");
std::fs::write(&path, &bytes).unwrap();
let p = HostKeyVerification::KnownHosts(path.clone());
let result = verify_fingerprint(&p, "prod-1", 22, "SHA256:ATTACKER");
let _ = std::fs::remove_file(&path);
if let Ok(true) = result {
panic!("SEC-2: corrupt known_hosts silently accepted attacker key (TOFU bypass)");
}
}
#[test]
fn known_hosts_absent_file_tofu_preserved() {
let dir = std::env::temp_dir();
let path = dir.join(format!("execkit_kh_absent_{}", std::process::id()));
let _ = std::fs::remove_file(&path);
let p = HostKeyVerification::KnownHosts(path.clone());
assert!(
verify_fingerprint(&p, "new-host", 22, "SHA256:firstkey").unwrap(),
"TOFU must accept first-ever connection when known_hosts is absent"
);
let _ = std::fs::remove_file(&path);
}
fn unique_path(tag: &str) -> PathBuf {
std::env::temp_dir().join(format!(
"execkit_kh_{tag}_{}_{}",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos()
))
}
#[test]
fn tofu_writes_bracketed_host_port_key_for_non_default_port() {
let path = unique_path("bracket");
let _ = std::fs::remove_file(&path);
assert!(verify_known_hosts(&path, "h", 2222, "SHA256:x").unwrap());
let content = std::fs::read_to_string(&path).unwrap();
assert!(
content.contains("[h]:2222 SHA256:x"),
"expected bracketed host:port entry, got: {content:?}"
);
let _ = std::fs::remove_file(&path);
}
#[test]
fn same_host_different_port_is_a_separate_unknown_entry() {
let path = unique_path("portsep");
let _ = std::fs::remove_file(&path);
assert!(verify_known_hosts(&path, "h", 2222, "SHA256:x").unwrap());
assert!(verify_known_hosts(&path, "h", 22, "SHA256:y").unwrap());
let content = std::fs::read_to_string(&path).unwrap();
assert!(content.contains("[h]:2222 SHA256:x"));
assert!(content.contains("h SHA256:y"));
let _ = std::fs::remove_file(&path);
}
#[test]
fn mismatch_on_same_host_and_port_is_rejected() {
let path = unique_path("mismatch");
let _ = std::fs::remove_file(&path);
assert!(verify_known_hosts(&path, "h", 2222, "SHA256:good").unwrap());
assert!(!verify_known_hosts(&path, "h", 2222, "SHA256:evil").unwrap());
let _ = std::fs::remove_file(&path);
}
#[test]
fn openssh_format_file_and_unknown_host_errors_with_guidance() {
let path = unique_path("openssh_unknown");
std::fs::write(&path, "otherhost ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAA\n").unwrap();
let err = verify_known_hosts(&path, "newhost", 22, "SHA256:whatever")
.expect_err("OpenSSH-format file must block TOFU for an unknown host");
let msg = err.to_string();
assert!(
msg.contains("OpenSSH format"),
"error should mention OpenSSH format, got: {msg}"
);
let _ = std::fs::remove_file(&path);
}
#[test]
fn openssh_line_for_same_host_is_ignored_not_treated_as_mismatch() {
let path = unique_path("openssh_sameone");
std::fs::write(&path, "samehost ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAA\n").unwrap();
let result = verify_known_hosts(&path, "samehost", 22, "SHA256:x");
assert!(
!matches!(result, Ok(false)),
"OpenSSH line must never be treated as a fingerprint mismatch"
);
assert!(
result.is_err(),
"expected Err (foreign-format file present)"
);
let _ = std::fs::remove_file(&path);
}
#[test]
fn openssh_line_for_other_host_does_not_block_known_execkit_host() {
let path = unique_path("mixed_format");
std::fs::write(
&path,
"otherhost ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAA\nA SHA256:good\n",
)
.unwrap();
assert!(
verify_known_hosts(&path, "A", 22, "SHA256:good").unwrap(),
"known execkit host must verify despite a foreign line elsewhere in the file"
);
assert!(
!verify_known_hosts(&path, "A", 22, "SHA256:bad").unwrap(),
"a mismatched fingerprint for the known host must still be rejected"
);
let _ = std::fs::remove_file(&path);
}
#[cfg(unix)]
#[test]
fn default_known_hosts_parent_is_forced_to_0700_even_if_preexisting() {
use std::os::unix::fs::PermissionsExt;
let base = unique_path("preexisting_execkit_dir");
let parent = base.join(".execkit");
std::fs::create_dir_all(&parent).unwrap();
std::fs::set_permissions(&parent, std::fs::Permissions::from_mode(0o755)).unwrap();
let path = parent.join("known_hosts");
assert!(verify_known_hosts(&path, "h", 22, "SHA256:x").unwrap());
let mode = std::fs::metadata(&parent).unwrap().permissions().mode() & 0o777;
assert_eq!(
mode, 0o700,
"pre-existing ~/.execkit must be tightened to 0700"
);
let _ = std::fs::remove_dir_all(&base);
}
#[cfg(unix)]
#[test]
fn custom_known_hosts_parent_permissions_are_never_touched() {
use std::os::unix::fs::PermissionsExt;
let base = unique_path("preexisting_custom_dir");
let parent = base.join("my-custom-ssh-state");
std::fs::create_dir_all(&parent).unwrap();
std::fs::set_permissions(&parent, std::fs::Permissions::from_mode(0o755)).unwrap();
let path = parent.join("known_hosts");
assert!(verify_known_hosts(&path, "h", 22, "SHA256:x").unwrap());
let mode = std::fs::metadata(&parent).unwrap().permissions().mode() & 0o777;
assert_eq!(
mode, 0o755,
"a custom known_hosts directory's permissions must be left alone"
);
let _ = std::fs::remove_dir_all(&base);
}
#[cfg(feature = "ssh")]
#[test]
fn ssh_connect_times_out_when_peer_never_speaks_ssh() {
use std::net::TcpListener;
use std::time::{Duration, Instant};
use super::SshTransport;
let listener = TcpListener::bind("127.0.0.1:0").expect("bind ephemeral loopback port");
let port = listener.local_addr().unwrap().port();
std::thread::spawn(move || {
if let Ok((stream, _)) = listener.accept() {
std::thread::sleep(Duration::from_secs(5));
drop(stream);
}
});
let mut cfg = SshConfig::new(
"127.0.0.1",
"nobody",
SshAuth::Password("x".into()),
HostKeyVerification::AcceptAny,
);
cfg.port = port;
cfg.connect_timeout = Duration::from_secs(1);
let start = Instant::now();
let err = match SshTransport::connect(cfg) {
Ok(_) => panic!("connect to a silent peer must fail"),
Err(e) => e,
};
let elapsed = start.elapsed();
assert!(
elapsed < Duration::from_secs(3),
"connect took too long: {elapsed:?}"
);
let msg = err.to_string();
assert!(
msg.contains("timed out after 1s"),
"expected a timeout message, got: {msg}"
);
}
}