rust: SSH client on russh (auth chain, TOTP, jump hosts, proxies, forwards, agent forwarding) and SFTP, with sshd integration tests

This commit is contained in:
Andy Dixon
2026-10-11 16:14:55 +01:00
parent 38dff53485
commit 026f27add6
6 changed files with 2674 additions and 59 deletions
+6
View File
@@ -14,3 +14,9 @@ aes-gcm = "0.10"
rand = "0.8"
base64 = "0.22"
keyring = { version = "3", features = ["apple-native", "windows-native", "async-secret-service", "tokio", "crypto-rust"] }
russh = { version = "0.64.1", default-features = false, features = ["flate2", "ring", "rsa"] }
russh-sftp = "3.0.1"
tokio = { version = "1.53.2", features = ["full"] }
sha2 = "0.11.0"
hmac = "0.13.0"
sha1 = "0.11.0"
+12 -1
View File
@@ -6,7 +6,18 @@
//! - `write`/`resize`/`close` are callable from any thread; `close` is idempotent and must not
//! fire `on_close` re-entrantly while holding locks.
use std::sync::Arc;
pub mod sftp;
pub mod ssh;
use std::sync::{Arc, OnceLock};
/// The shared tokio runtime for async protocol stacks (russh). Blocking clients use threads.
pub fn rt() -> &'static tokio::runtime::Runtime {
static RT: OnceLock<tokio::runtime::Runtime> = OnceLock::new();
RT.get_or_init(|| {
tokio::runtime::Builder::new_multi_thread().worker_threads(2).enable_all().thread_name("nexus-net").build().expect("tokio runtime")
})
}
pub type DataFn = Arc<dyn Fn(&[u8]) + Send + Sync>;
pub type ErrorFn = Arc<dyn Fn(String) + Send + Sync>;
+152
View File
@@ -0,0 +1,152 @@
//! SFTP operations over an SSH session's subsystem (internal/sftpclient). Every call blocks the
//! caller, so the UI runs them on worker threads.
use super::rt;
use crate::Result;
use russh_sftp::client::SftpSession;
use std::sync::Arc;
use std::time::{Duration, Instant};
use tokio::io::{AsyncReadExt, AsyncWriteExt};
#[derive(Clone, Debug, PartialEq)]
pub struct FileEntry {
pub name: String,
/// Full remote path.
pub path: String,
pub size: u64,
pub is_dir: bool,
/// Unix permission string, e.g. "-rwxr-xr-x".
pub mode: String,
/// Unix epoch milliseconds.
pub modified_at: i64,
}
#[derive(Clone, Debug, Default)]
pub struct Progress {
pub transferred: u64,
pub total: u64,
pub path: String,
pub done: bool,
pub error: String,
}
fn mode_string(m: &russh_sftp::protocol::FileAttributes) -> String {
let kind = if m.is_dir() { 'd' } else if m.is_symlink() { 'L' } else { '-' };
format!("{kind}{}", m.permissions())
}
fn entry(name: String, path: String, m: &russh_sftp::protocol::FileAttributes) -> FileEntry {
FileEntry {
name,
path,
size: m.size.unwrap_or(0),
is_dir: m.is_dir(),
mode: mode_string(m),
modified_at: m.mtime.map_or(0, |t| t as i64 * 1000),
}
}
/// `path.Join` for remote (always '/') paths.
pub fn join(dir: &str, name: &str) -> String {
if dir.is_empty() {
name.to_string()
} else if dir.ends_with('/') {
format!("{dir}{name}")
} else {
format!("{dir}/{name}")
}
}
/// Directories first, then case-insensitive name order.
pub fn list(s: &Arc<SftpSession>, path: &str) -> Result<Vec<FileEntry>> {
let rd = rt().block_on(s.read_dir(path)).map_err(|e| format!("sftpclient: failed to list {path}: {e}"))?;
let mut out: Vec<FileEntry> = rd
.filter(|e| e.file_name() != "." && e.file_name() != "..")
.map(|e| entry(e.file_name(), join(path, &e.file_name()), &e.metadata()))
.collect();
out.sort_by(|a, b| b.is_dir.cmp(&a.is_dir).then_with(|| a.name.to_lowercase().cmp(&b.name.to_lowercase())));
Ok(out)
}
pub fn stat(s: &Arc<SftpSession>, path: &str) -> Result<FileEntry> {
let m = rt().block_on(s.metadata(path)).map_err(|e| format!("sftpclient: stat failed for {path}: {e}"))?;
let name = path.rsplit('/').next().unwrap_or(path).to_string();
Ok(entry(name, path.to_string(), &m))
}
pub fn mkdir(s: &Arc<SftpSession>, path: &str) -> Result<()> {
Ok(rt().block_on(s.create_dir(path))?)
}
/// Removes a file, or an empty directory.
pub fn remove(s: &Arc<SftpSession>, path: &str) -> Result<()> {
rt().block_on(async {
match s.remove_file(path).await {
Ok(()) => Ok(()),
Err(e) => s.remove_dir(path).await.map_err(|_| e),
}
})?;
Ok(())
}
pub fn rename(s: &Arc<SftpSession>, from: &str, to: &str) -> Result<()> {
Ok(rt().block_on(s.rename(from, to))?)
}
pub fn pwd(s: &Arc<SftpSession>) -> Result<String> {
Ok(rt().block_on(s.canonicalize("."))?)
}
async fn pump(
mut src: impl tokio::io::AsyncRead + Unpin,
mut dst: impl tokio::io::AsyncWrite + Unpin,
total: u64,
path: &str,
on: &dyn Fn(Progress),
) -> Result<()> {
let mut buf = vec![0u8; 32 * 1024];
let (mut done, mut last) = (0u64, Instant::now());
loop {
let n = src.read(&mut buf).await.map_err(|e| format!("sftpclient: read failed: {e}"))?;
if n == 0 {
break;
}
dst.write_all(&buf[..n]).await.map_err(|e| format!("sftpclient: write failed: {e}"))?;
done += n as u64;
if last.elapsed() > Duration::from_millis(100) {
on(Progress { transferred: done, total, path: path.into(), ..Default::default() });
last = Instant::now();
}
}
dst.flush().await?;
dst.shutdown().await?;
on(Progress { transferred: done, total, path: path.into(), done: true, ..Default::default() });
Ok(())
}
pub fn download(s: &Arc<SftpSession>, remote: &str, local: &std::path::Path, on: &dyn Fn(Progress)) -> Result<()> {
rt().block_on(async {
let total = s.metadata(remote).await.map_err(|e| format!("sftpclient: stat failed: {e}"))?.size.unwrap_or(0);
let src = s.open(remote).await.map_err(|e| format!("sftpclient: failed to open remote file: {e}"))?;
let dst = tokio::fs::File::create(local).await.map_err(|e| format!("sftpclient: failed to create local file: {e}"))?;
pump(src, dst, total, remote, on).await
})
}
pub fn upload(s: &Arc<SftpSession>, local: &std::path::Path, remote: &str, on: &dyn Fn(Progress)) -> Result<()> {
rt().block_on(async {
let src = tokio::fs::File::open(local).await.map_err(|e| format!("sftpclient: failed to open local file: {e}"))?;
let total = src.metadata().await?.len();
let dst = s.create(remote).await.map_err(|e| format!("sftpclient: failed to create remote file: {e}"))?;
pump(src, dst, total, remote, on).await
})
}
#[cfg(test)]
mod tests {
#[test]
fn join_paths() {
assert_eq!(super::join("/", "a"), "/a");
assert_eq!(super::join("/home/x", "a"), "/home/x/a");
}
}
+766
View File
@@ -0,0 +1,766 @@
//! SSH client on russh: interactive PTY shell, the profile's auth chain (agent, public key,
//! password, keyboard-interactive with TOTP), jump hosts, SOCKS5/HTTP proxies, local and remote
//! port forwards, agent forwarding and SFTP. A port of internal/sshclient + sftpclient.
use super::{Callbacks, rt};
use crate::models::{ForwardDirection, JumpHost, PortForward, Profile, SshConfig};
use crate::Result;
use base64::Engine;
use russh::client::{self, Handle, Msg};
use russh::keys::{HashAlg, PrivateKeyWithHashAlg, PublicKey};
use russh::{Channel, ChannelMsg, Pty};
use std::collections::HashMap;
use std::sync::{Arc, Mutex};
use std::time::{Duration, Instant};
use tokio::io::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt};
use tokio::sync::mpsc;
const MAX_PRIVATE_KEY_BYTES: u64 = 1 << 20;
/// What a host-key decision needs. The manager's checker consults the known-hosts table and,
/// per policy, prompts the user; it returns Err with the reason to reject.
#[derive(Clone, Debug)]
pub struct HostKeyInfo {
pub host: String,
pub port: u16,
pub key_type: String,
pub fingerprint: String,
pub public_key_b64: String,
}
pub type HostKeyCheck = Arc<dyn Fn(&HostKeyInfo) -> std::result::Result<(), String> + Send + Sync>;
/// Blocking credential source (may prompt the user).
pub type SecretFn = Arc<dyn Fn() -> Result<String> + Send + Sync>;
pub fn host_key_info(host: &str, port: u16, key: &PublicKey) -> HostKeyInfo {
HostKeyInfo {
host: host.into(),
port,
key_type: key.algorithm().as_str().into(),
fingerprint: key.fingerprint(HashAlg::Sha256).to_string(),
public_key_b64: base64::engine::general_purpose::STANDARD.encode(key.to_bytes().unwrap_or_default()),
}
}
/// State the russh handler shares with the client: remote-forward targets and the last
/// host-key rejection reason (russh only reports "unknown key").
#[derive(Default)]
struct Shared {
remote_targets: Mutex<HashMap<(String, u32), (String, u16)>>,
rejection: Mutex<Option<String>>,
agent_forwarding: std::sync::atomic::AtomicBool,
}
struct Handler {
host: String,
port: u16,
check: HostKeyCheck,
shared: Arc<Shared>,
}
impl client::Handler for Handler {
type Error = russh::Error;
async fn check_server_key(&mut self, key: &russh::keys::PublicKeyOrCertificate) -> std::result::Result<bool, Self::Error> {
let key = match key {
russh::keys::PublicKeyOrCertificate::PublicKey { key, .. } => key.clone(),
russh::keys::PublicKeyOrCertificate::Certificate(c) => c.public_key().clone().into(),
};
let info = host_key_info(&self.host, self.port, &key);
let check = self.check.clone();
// The check may block on a UI prompt.
let res = tokio::task::spawn_blocking(move || check(&info)).await.unwrap_or(Err("host key check failed".into()));
match res {
Ok(()) => Ok(true),
Err(why) => {
*self.shared.rejection.lock().unwrap() = Some(why);
Ok(false)
}
}
}
async fn server_channel_open_forwarded_tcpip(
&mut self,
channel: Channel<Msg>,
connected_address: &str,
connected_port: u32,
_originator_address: &str,
_originator_port: u32,
reply: client::ChannelOpenHandle,
_session: &mut client::Session,
) -> std::result::Result<(), Self::Error> {
let dest = self.shared.remote_targets.lock().unwrap().get(&(connected_address.to_string(), connected_port)).cloned();
let Some((host, port)) = dest else {
reply.reject(russh::ChannelOpenFailure::AdministrativelyProhibited).await;
return Ok(());
};
reply.accept().await;
tokio::spawn(async move {
if let Ok(mut local) = tokio::net::TcpStream::connect((host.as_str(), port)).await {
let mut remote = channel.into_stream();
let _ = tokio::io::copy_bidirectional(&mut remote, &mut local).await;
}
});
Ok(())
}
async fn server_channel_open_agent_forward(
&mut self,
channel: Channel<Msg>,
reply: client::ChannelOpenHandle,
_session: &mut client::Session,
) -> std::result::Result<(), Self::Error> {
if !self.shared.agent_forwarding.load(std::sync::atomic::Ordering::Relaxed) {
reply.reject(russh::ChannelOpenFailure::AdministrativelyProhibited).await;
return Ok(());
}
reply.accept().await;
tokio::spawn(async move {
if let Ok(mut agent) = connect_agent_socket().await {
let mut remote = channel.into_stream();
let _ = tokio::io::copy_bidirectional(&mut remote, &mut agent).await;
}
});
Ok(())
}
}
trait Stream: AsyncRead + AsyncWrite + Unpin + Send {}
impl<T: AsyncRead + AsyncWrite + Unpin + Send> Stream for T {}
/// The local agent's socket: `SSH_AUTH_SOCK` on Unix, the OpenSSH named pipe on Windows.
async fn connect_agent_socket() -> std::io::Result<Box<dyn Stream>> {
#[cfg(unix)]
{
let p = std::env::var("SSH_AUTH_SOCK").map_err(|_| std::io::Error::other("SSH_AUTH_SOCK not set — SSH agent unavailable"))?;
Ok(Box::new(tokio::net::UnixStream::connect(p).await?))
}
#[cfg(windows)]
{
let p = std::env::var("SSH_AUTH_SOCK").unwrap_or_else(|_| r"\\.\pipe\openssh-ssh-agent".into());
Ok(Box::new(tokio::net::windows::named_pipe::ClientOptions::new().open(p)?))
}
}
type Agent = russh::keys::agent::client::AgentClient<Box<dyn russh::keys::agent::client::AgentStream + Send + Unpin>>;
async fn connect_agent() -> Result<Agent> {
#[cfg(unix)]
{
Ok(russh::keys::agent::client::AgentClient::connect_env().await.map_err(|e| format!("SSH agent unavailable: {e}"))?.dynamic())
}
#[cfg(windows)]
{
if let Ok(a) = russh::keys::agent::client::AgentClient::connect_named_pipe(r"\\.\pipe\openssh-ssh-agent").await {
return Ok(a.dynamic());
}
Ok(russh::keys::agent::client::AgentClient::connect_pageant().await.map_err(|e| format!("SSH agent unavailable: {e}"))?.dynamic())
}
}
/// Profile env vars plus a UTF-8 LC_CTYPE when the profile sets no locale (Go's SSHEnvVars).
fn ssh_env_vars(env: &std::collections::BTreeMap<String, String>) -> std::collections::BTreeMap<String, String> {
let mut out: std::collections::BTreeMap<String, String> =
env.iter().filter(|(k, _)| !k.trim().is_empty()).map(|(k, v)| (k.trim().to_string(), v.clone())).collect();
if !["LC_ALL", "LC_CTYPE", "LANG"].iter().any(|k| out.contains_key(*k)) {
out.insert("LC_CTYPE".into(), if cfg!(target_os = "macos") { "en_US.UTF-8" } else { "C.UTF-8" }.into());
}
out
}
fn is_totp_prompt(q: &str) -> bool {
let q = q.to_lowercase();
[
"verification code", "otp", "one-time", "one time", "two-factor", "two factor", "2fa", "totp", "authenticator",
"security code", "6-digit", "6 digit",
]
.iter()
.any(|k| q.contains(k))
}
/// RFC 6238 code (SHA-1, 6 digits, 30 s) for a base32 secret, normalised as pquerna/otp does.
pub fn totp_code(secret: &str) -> Result<String> {
totp_at(secret, std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap_or_default().as_secs())
}
fn totp_at(secret: &str, unix: u64) -> Result<String> {
use hmac::{Hmac, KeyInit, Mac};
let key = base32_decode(secret).ok_or("invalid TOTP secret (not base32)")?;
let mut mac = Hmac::<sha1::Sha1>::new_from_slice(&key).map_err(|e| e.to_string())?;
mac.update(&(unix / 30).to_be_bytes());
let h = mac.finalize().into_bytes();
let o = (h[19] & 0xf) as usize;
let bin = u32::from_be_bytes([h[o] & 0x7f, h[o + 1], h[o + 2], h[o + 3]]);
Ok(format!("{:06}", bin % 1_000_000))
}
fn base32_decode(s: &str) -> Option<Vec<u8>> {
let (mut out, mut buf, mut bits) = (vec![], 0u64, 0u32);
for c in s.chars().filter(|c| !c.is_whitespace() && *c != '=') {
let v = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567".find(c.to_ascii_uppercase())? as u64;
buf = (buf << 5) | v;
bits += 5;
if bits >= 8 {
bits -= 8;
out.push((buf >> bits) as u8);
}
}
(!out.is_empty()).then_some(out)
}
/// Reads an OpenSSH/PEM private key, asking for a passphrase only if it's encrypted.
pub fn load_private_key(path: &str, passphrase: Option<&SecretFn>) -> Result<russh::keys::PrivateKey> {
let meta = std::fs::metadata(path).map_err(|e| format!("unable to stat key file: {e}"))?;
if meta.is_dir() {
return Err("key path is a directory".into());
}
if meta.len() > MAX_PRIVATE_KEY_BYTES {
return Err(format!("key file is too large: {} bytes exceeds {MAX_PRIVATE_KEY_BYTES} bytes", meta.len()).into());
}
let data = std::fs::read_to_string(path).map_err(|e| format!("unable to read key file: {e}"))?;
match russh::keys::decode_secret_key(&data, None) {
Ok(k) => Ok(k),
Err(first) => {
let Some(get) = passphrase else {
return Err(format!("key is encrypted and no passphrase callback provided: {first}").into());
};
let pass = get().map_err(|e| format!("failed to get passphrase: {e}"))?;
russh::keys::decode_secret_key(&data, Some(&pass)).map_err(|e| format!("failed to parse encrypted key: {e}").into())
}
}
}
async fn blocking<T: Send + 'static>(f: impl FnOnce() -> T + Send + 'static) -> T {
tokio::task::spawn_blocking(f).await.expect("blocking task panicked")
}
async fn dial(sc: &SshConfig, host: &str, port: u16, timeout: Duration) -> Result<tokio::net::TcpStream> {
let fut = async {
match sc.proxy_type.to_lowercase().as_str() {
"socks5" => socks5(sc, host, port).await,
"http" => http_connect(sc, host, port).await,
_ => Ok(tokio::net::TcpStream::connect((host, port)).await?),
}
};
tokio::time::timeout(timeout, fut).await.map_err(|_| format!("dial tcp {host}:{port}: i/o timeout"))?
}
async fn socks5(sc: &SshConfig, host: &str, port: u16) -> Result<tokio::net::TcpStream> {
let pport = if sc.proxy_port == 0 { 1080 } else { sc.proxy_port as u16 };
let mut s = tokio::net::TcpStream::connect((sc.proxy_host.as_str(), pport)).await.map_err(|e| format!("SOCKS5 proxy setup failed: {e}"))?;
let auth = !sc.proxy_username.is_empty();
s.write_all(if auth { &[5, 2, 0, 2] } else { &[5, 1, 0] }).await?;
let mut r = [0u8; 2];
s.read_exact(&mut r).await?;
match r[1] {
0 => {}
2 if auth => {
let mut m = vec![1, sc.proxy_username.len() as u8];
m.extend(sc.proxy_username.as_bytes());
m.push(sc.proxy_password.len() as u8);
m.extend(sc.proxy_password.as_bytes());
s.write_all(&m).await?;
s.read_exact(&mut r).await?;
if r[1] != 0 {
return Err("SOCKS5 proxy: authentication failed".into());
}
}
_ => return Err("SOCKS5 proxy: no acceptable authentication method".into()),
}
let mut req = vec![5, 1, 0, 3, host.len() as u8];
req.extend(host.as_bytes());
req.extend(port.to_be_bytes());
s.write_all(&req).await?;
let mut head = [0u8; 4];
s.read_exact(&mut head).await?;
if head[1] != 0 {
return Err(format!("SOCKS5 proxy connection to {host}:{port} failed: reply code {}", head[1]).into());
}
let skip = match head[3] {
1 => 4,
4 => 16,
3 => s.read_u8().await? as usize,
_ => 0,
} + 2;
let mut rest = vec![0u8; skip];
s.read_exact(&mut rest).await?;
Ok(s)
}
async fn http_connect(sc: &SshConfig, host: &str, port: u16) -> Result<tokio::net::TcpStream> {
let pport = if sc.proxy_port == 0 { 8080 } else { sc.proxy_port as u16 };
let mut s = tokio::net::TcpStream::connect((sc.proxy_host.as_str(), pport)).await.map_err(|e| format!("HTTP proxy connection failed: {e}"))?;
let mut req = format!("CONNECT {host}:{port} HTTP/1.1\r\nHost: {host}:{port}\r\n");
if !sc.proxy_username.is_empty() {
let cred = base64::engine::general_purpose::STANDARD.encode(format!("{}:{}", sc.proxy_username, sc.proxy_password));
req += &format!("Proxy-Authorization: Basic {cred}\r\n");
}
req += "\r\n";
s.write_all(req.as_bytes()).await?;
// Read the response head byte by byte so no tunnelled bytes are consumed.
let mut head = Vec::new();
while !head.ends_with(b"\r\n\r\n") {
head.push(s.read_u8().await.map_err(|e| format!("HTTP CONNECT response read failed: {e}"))?);
if head.len() > 16 * 1024 {
return Err("HTTP CONNECT response too large".into());
}
}
let status = String::from_utf8_lossy(&head).lines().next().unwrap_or("").to_string();
if status.split_whitespace().nth(1) != Some("200") {
return Err(format!("HTTP CONNECT proxy returned {status}").into());
}
Ok(s)
}
fn config(sc: &SshConfig) -> Arc<client::Config> {
let mut c = client::Config::default();
if sc.keepalive_interval > 0 {
c.keepalive_interval = Some(Duration::from_secs(sc.keepalive_interval as u64));
c.keepalive_max = sc.keepalive_count_max.max(1) as usize;
}
c.preferred.compression = if sc.compression {
vec![russh::compression::ZLIB_LEGACY, russh::compression::ZLIB, russh::compression::NONE].into()
} else {
vec![russh::compression::NONE].into()
};
c.nodelay = true;
Arc::new(c)
}
struct Auth<'a> {
user: &'a str,
methods: Vec<String>,
agent: bool,
key: Option<russh::keys::PrivateKey>,
password: Option<SecretFn>,
totp_secret: String,
}
async fn authenticate(h: &mut Handle<Handler>, a: Auth<'_>) -> Result<()> {
if a.agent
&& let Ok(mut agent) = connect_agent().await
&& let Ok(ids) = agent.request_identities().await
{
for id in ids {
let key = id.public_key().into_owned();
let hash = if key.algorithm().is_rsa() { h.best_supported_rsa_hash().await.ok().flatten().flatten() } else { None };
if let Ok(r) = h.authenticate_publickey_with(a.user, key, hash, &mut agent).await
&& r.success()
{
return Ok(());
}
}
}
for m in &a.methods {
match m.as_str() {
"publickey" => {
if let Some(k) = &a.key {
let hash = if k.algorithm().is_rsa() { h.best_supported_rsa_hash().await?.flatten() } else { None };
if h.authenticate_publickey(a.user, PrivateKeyWithHashAlg::new(Arc::new(k.clone()), hash)).await?.success() {
return Ok(());
}
}
}
"password" => {
if let Some(get) = a.password.clone() {
let pw = blocking(move || get()).await?;
if h.authenticate_password(a.user, pw).await?.success() {
return Ok(());
}
}
}
"keyboard-interactive" => {
let Some(get) = a.password.clone() else { continue };
let mut resp = h.authenticate_keyboard_interactive_start(a.user, None).await?;
loop {
match resp {
client::KeyboardInteractiveAuthResponse::Success => return Ok(()),
client::KeyboardInteractiveAuthResponse::Failure { .. } => break,
client::KeyboardInteractiveAuthResponse::InfoRequest { prompts, .. } => {
let mut answers = vec![];
for p in prompts {
if !a.totp_secret.is_empty() && is_totp_prompt(&p.prompt) {
answers.push(totp_code(&a.totp_secret).map_err(|e| format!("failed to generate TOTP code: {e}"))?);
} else {
let get = get.clone();
answers.push(blocking(move || get()).await?);
}
}
resp = h.authenticate_keyboard_interactive_respond(answers).await?;
}
}
}
}
_ => {}
}
}
Err("ssh: handshake failed: ssh: unable to authenticate, no supported methods remain".into())
}
enum Cmd {
Data(Vec<u8>),
Resize(u32, u32),
}
struct Forward {
rule: PortForward,
tasks: Arc<Mutex<Vec<tokio::task::AbortHandle>>>,
}
pub struct SshClient {
profile: Profile,
cb: Callbacks,
shared: Arc<Shared>,
handle: Mutex<Option<Arc<Handle<Handler>>>>,
/// Earlier hops of a jump chain; kept alive for the session's lifetime.
hops: Mutex<Vec<Arc<Handle<Handler>>>>,
tx: Mutex<Option<mpsc::UnboundedSender<Cmd>>>,
forwards: Mutex<HashMap<String, Forward>>,
sftp: tokio::sync::Mutex<Option<Arc<russh_sftp::client::SftpSession>>>,
closed: std::sync::atomic::AtomicBool,
}
impl SshClient {
pub fn new(profile: &Profile, cb: Callbacks) -> Arc<SshClient> {
Arc::new(SshClient {
profile: profile.clone(),
cb,
shared: Arc::default(),
handle: Mutex::new(None),
hops: Mutex::new(vec![]),
tx: Mutex::new(None),
forwards: Mutex::new(HashMap::new()),
sftp: tokio::sync::Mutex::new(None),
closed: false.into(),
})
}
fn sc(&self) -> SshConfig {
self.profile.ssh.clone().unwrap_or_else(SshConfig::defaults)
}
/// Connects, authenticates and starts the shell. Blocks the calling thread.
pub fn connect(self: &Arc<Self>, check: HostKeyCheck, password: Option<SecretFn>, passphrase: Option<SecretFn>) -> Result<()> {
let me = self.clone();
rt().block_on(async move { me.connect_async(check, password, passphrase).await })
}
async fn handshake(
&self,
stream: impl AsyncRead + AsyncWrite + Unpin + Send + 'static,
host: &str,
port: u16,
check: &HostKeyCheck,
) -> Result<Handle<Handler>> {
let handler = Handler { host: host.into(), port, check: check.clone(), shared: self.shared.clone() };
let timeout = Duration::from_secs(self.profile.connect_timeout.max(1) as u64);
match tokio::time::timeout(timeout, client::connect_stream(config(&self.sc()), stream, handler)).await {
Err(_) => Err(format!("SSH handshake with {host}:{port} timed out").into()),
Ok(Ok(h)) => Ok(h),
Ok(Err(e)) => match self.shared.rejection.lock().unwrap().take() {
Some(why) => Err(why.into()),
None => Err(format!("SSH handshake failed: {e}").into()),
},
}
}
async fn connect_async(self: &Arc<Self>, check: HostKeyCheck, password: Option<SecretFn>, passphrase: Option<SecretFn>) -> Result<()> {
let sc = self.sc();
let p = &self.profile;
let port = if p.port == 0 { 22 } else { p.port as u16 };
let timeout = Duration::from_secs(if p.connect_timeout == 0 { 30 } else { p.connect_timeout as u64 });
let methods = if sc.auth_methods.is_empty() { SshConfig::defaults().auth_methods } else { sc.auth_methods.clone() };
let key = if methods.iter().any(|m| m == "publickey") && !sc.private_key_path.is_empty() {
let (path, pp) = (sc.private_key_path.clone(), passphrase.clone());
Some(blocking(move || load_private_key(&path, pp.as_ref())).await.map_err(|e| format!("failed to build auth methods: failed to parse private key {}: {e}", sc.private_key_path))?)
} else {
None
};
let mut handle = if sc.jump_hosts.is_empty() {
let tcp = dial(&sc, &p.host, port, timeout).await.map_err(|e| format!("SSH connection failed: {e}"))?;
self.handshake(tcp, &p.host, port, &check).await?
} else {
let (prev, h) = self.via_jumps(&sc, &check, passphrase.clone(), timeout).await?;
let ch = prev.channel_open_direct_tcpip(p.host.clone(), port as u32, "127.0.0.1", 0).await.map_err(|e| format!("failed to dial target {}:{port} through jump hosts: {e}", p.host))?;
self.hops.lock().unwrap().extend(h);
self.hops.lock().unwrap().push(prev);
self.handshake(ch.into_stream(), &p.host, port, &check).await?
};
authenticate(
&mut handle,
Auth { user: &p.username, methods, agent: sc.agent_auth, key, password, totp_secret: sc.totp_secret.clone() },
)
.await?;
let handle = Arc::new(handle);
let ch = handle.channel_open_session().await.map_err(|e| format!("failed to create SSH session: {e}"))?;
for (k, v) in ssh_env_vars(&sc.env_vars) {
let _ = ch.set_env(false, k, v).await;
}
let term = if sc.terminal_type.is_empty() { "xterm-256color" } else { &sc.terminal_type };
let modes = [(Pty::ECHO, 1), (Pty::TTY_OP_ISPEED, 115200), (Pty::TTY_OP_OSPEED, 115200), (Pty::IUTF8, 1)];
ch.request_pty(true, term, 80, 24, 0, 0, &modes).await.map_err(|e| format!("PTY request failed: {e}"))?;
if sc.agent_forwarding {
self.shared.agent_forwarding.store(true, std::sync::atomic::Ordering::Relaxed);
if let Err(e) = ch.agent_forward(false).await {
(self.cb.on_error)(format!("agent forwarding: {e}"));
}
}
if sc.startup_command.is_empty() {
ch.request_shell(true).await.map_err(|e| format!("failed to start shell: {e}"))?;
} else {
ch.exec(true, sc.startup_command.as_bytes()).await.map_err(|e| format!("failed to start command: {e}"))?;
}
let (mut read, write) = ch.split();
let (tx, mut rx) = mpsc::unbounded_channel::<Cmd>();
*self.tx.lock().unwrap() = Some(tx);
*self.handle.lock().unwrap() = Some(handle);
tokio::spawn(async move {
while let Some(cmd) = rx.recv().await {
let r = match cmd {
Cmd::Data(d) => write.data_bytes(d).await,
Cmd::Resize(c, r) => write.window_change(c, r, 0, 0).await,
};
if r.is_err() {
break;
}
}
});
let me = self.clone();
tokio::spawn(async move {
while let Some(msg) = read.wait().await {
match msg {
ChannelMsg::Data { data } | ChannelMsg::ExtendedData { data, .. } => (me.cb.on_data)(&data),
ChannelMsg::Eof | ChannelMsg::Close => break,
_ => {}
}
}
let me2 = me.clone();
let _ = tokio::task::spawn_blocking(move || me2.close()).await;
});
Ok(())
}
/// Authenticates through each jump host in turn; returns the last hop and the ones before it.
async fn via_jumps(&self, sc: &SshConfig, check: &HostKeyCheck, passphrase: Option<SecretFn>, timeout: Duration) -> Result<(Arc<Handle<Handler>>, Vec<Arc<Handle<Handler>>>)> {
let mut chain: Vec<Arc<Handle<Handler>>> = vec![];
for (i, j) in sc.jump_hosts.iter().enumerate() {
let JumpHost { host, port, username, private_key_path, .. } = j;
let jport = if *port == 0 { 22 } else { *port as u16 };
let key = if private_key_path.is_empty() {
None
} else {
let (path, pp) = (private_key_path.clone(), passphrase.clone());
Some(blocking(move || load_private_key(&path, pp.as_ref())).await.map_err(|e| format!("jump host {i} private key error: {e}"))?)
};
let mut h = match chain.last() {
None => {
let tcp = tokio::time::timeout(timeout, tokio::net::TcpStream::connect((host.as_str(), jport)))
.await
.map_err(|_| format!("failed to connect to jump host {i} ({host}:{jport}): timeout"))?
.map_err(|e| format!("failed to connect to jump host {i} ({host}:{jport}): {e}"))?;
self.handshake(tcp, host, jport, check).await?
}
Some(prev) => {
let ch = prev.channel_open_direct_tcpip(host.clone(), jport as u32, "127.0.0.1", 0).await.map_err(|e| format!("failed to dial through jump host {i} ({host}:{jport}): {e}"))?;
self.handshake(ch.into_stream(), host, jport, check).await?
}
};
authenticate(
&mut h,
Auth { user: username, methods: vec!["publickey".into()], agent: key.is_none() || sc.agent_auth, key, password: None, totp_secret: String::new() },
)
.await
.map_err(|e| format!("failed to establish SSH through jump host {i}: {e}"))?;
chain.push(Arc::new(h));
}
let last = chain.pop().expect("at least one jump host");
Ok((last, chain))
}
fn handle(&self) -> Result<Arc<Handle<Handler>>> {
self.handle.lock().unwrap().clone().ok_or_else(|| "not connected".into())
}
pub fn write(&self, data: &[u8]) -> Result<()> {
let tx = self.tx.lock().unwrap();
tx.as_ref().ok_or("not connected")?.send(Cmd::Data(data.to_vec())).map_err(|_| "not connected".into())
}
pub fn resize(&self, cols: u32, rows: u32) -> Result<()> {
let tx = self.tx.lock().unwrap();
tx.as_ref().ok_or("not connected")?.send(Cmd::Resize(cols, rows)).map_err(|_| "not connected".into())
}
/// Round trip of a keepalive, in milliseconds.
pub fn ping(&self) -> Result<i64> {
let h = self.handle()?;
let start = Instant::now();
rt().block_on(h.send_ping())?;
Ok(start.elapsed().as_millis() as i64)
}
/// Runs a command on a separate channel and returns trimmed stdout.
pub fn run_command(&self, command: &str) -> Result<String> {
let h = self.handle()?;
let command = command.to_string();
rt().block_on(async move {
let mut ch = h.channel_open_session().await.map_err(|e| format!("failed to open SSH session: {e}"))?;
ch.exec(true, command.as_bytes()).await?;
let mut out = vec![];
while let Some(m) = ch.wait().await {
match m {
ChannelMsg::Data { data } => out.extend_from_slice(&data),
ChannelMsg::Eof | ChannelMsg::Close => break,
_ => {}
}
}
Ok(String::from_utf8_lossy(&out).trim().to_string())
})
}
pub fn is_connected(&self) -> bool {
self.handle.lock().unwrap().as_ref().is_some_and(|h| !h.is_closed())
}
/// Idempotent; fires on_close once.
pub fn close(&self) {
if self.closed.swap(true, std::sync::atomic::Ordering::SeqCst) {
return;
}
self.stop_all_forwards();
*self.tx.lock().unwrap() = None;
let h = self.handle.lock().unwrap().take();
let hops = std::mem::take(&mut *self.hops.lock().unwrap());
rt().spawn(async move {
if let Some(h) = h {
let _ = h.disconnect(russh::Disconnect::ByApplication, "", "en").await;
}
for h in hops.into_iter().rev() {
let _ = h.disconnect(russh::Disconnect::ByApplication, "", "en").await;
}
});
(self.cb.on_close)();
}
// --- port forwarding -------------------------------------------------------------------
pub fn start_forward(&self, rule: PortForward) -> Result<()> {
if self.forwards.lock().unwrap().contains_key(&rule.id) {
return Err(format!("forward {:?} is already active", rule.id).into());
}
let h = self.handle()?;
let tasks: Arc<Mutex<Vec<tokio::task::AbortHandle>>> = Arc::default();
match rule.direction {
ForwardDirection::Local => {
let bind = if rule.bind_host.is_empty() { "127.0.0.1".to_string() } else { rule.bind_host.clone() };
let addr = format!("{bind}:{}", rule.bind_port);
let listener = rt()
.block_on(tokio::net::TcpListener::bind(addr.clone()))
.map_err(|e| format!("local forward: listen on {addr} failed: {e}"))?;
let (dhost, dport) = (rule.dest_host.clone(), rule.dest_port as u32);
let t2 = tasks.clone();
let task = rt().spawn(async move {
while let Ok((mut sock, peer)) = listener.accept().await {
let (h, dhost) = (h.clone(), dhost.clone());
let conn = tokio::spawn(async move {
if let Ok(ch) = h.channel_open_direct_tcpip(dhost, dport, peer.ip().to_string(), peer.port() as u32).await {
let mut remote = ch.into_stream();
let _ = tokio::io::copy_bidirectional(&mut sock, &mut remote).await;
}
});
t2.lock().unwrap().push(conn.abort_handle());
}
});
tasks.lock().unwrap().push(task.abort_handle());
}
ForwardDirection::Remote => {
let bind = if rule.bind_host.is_empty() { "0.0.0.0".to_string() } else { rule.bind_host.clone() };
let port = rt()
.block_on(h.tcpip_forward(bind.clone(), rule.bind_port as u32))
.map_err(|e| format!("remote forward: server listen on {bind}:{} failed: {e}", rule.bind_port))?;
let port = if rule.bind_port == 0 { port } else { rule.bind_port as u32 };
self.shared.remote_targets.lock().unwrap().insert((bind, port), (rule.dest_host.clone(), rule.dest_port as u16));
}
}
self.forwards.lock().unwrap().insert(rule.id.clone(), Forward { rule, tasks });
Ok(())
}
pub fn stop_forward(&self, id: &str) -> Result<()> {
let f = self.forwards.lock().unwrap().remove(id).ok_or_else(|| format!("forward {id:?} not found"))?;
self.shutdown_forward(f);
Ok(())
}
fn shutdown_forward(&self, f: Forward) {
for t in f.tasks.lock().unwrap().drain(..) {
t.abort();
}
if f.rule.direction == ForwardDirection::Remote {
let bind = if f.rule.bind_host.is_empty() { "0.0.0.0".to_string() } else { f.rule.bind_host.clone() };
self.shared.remote_targets.lock().unwrap().retain(|(a, p), _| !(a == &bind && *p == f.rule.bind_port as u32));
if let Ok(h) = self.handle() {
let port = f.rule.bind_port as u32;
rt().spawn(async move {
let _ = h.cancel_tcpip_forward(bind, port).await;
});
}
}
}
fn stop_all_forwards(&self) {
let all: Vec<Forward> = self.forwards.lock().unwrap().drain().map(|(_, f)| f).collect();
for f in all {
self.shutdown_forward(f);
}
}
pub fn active_forwards(&self) -> Vec<PortForward> {
self.forwards.lock().unwrap().values().map(|f| f.rule.clone()).collect()
}
// --- SFTP -------------------------------------------------------------------------------
/// The SFTP subsystem, opened on first use.
pub fn sftp(&self) -> Result<Arc<russh_sftp::client::SftpSession>> {
rt().block_on(async {
let mut slot = self.sftp.lock().await;
if let Some(s) = slot.as_ref() {
return Ok(s.clone());
}
let h = self.handle()?;
let ch = h.channel_open_session().await?;
ch.request_subsystem(true, "sftp").await?;
let s = Arc::new(
russh_sftp::client::SftpSession::new(ch.into_stream()).await.map_err(|e| format!("sftpclient: failed to start SFTP subsystem: {e}"))?,
);
*slot = Some(s.clone());
Ok(s)
})
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn totp_matches_rfc_vector() {
// RFC 6238 SHA-1 secret "12345678901234567890" is GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ in base32.
let secret = "gezd gnbv gy3t qojq gezd gnbv gy3t qojq";
assert_eq!(totp_at(secret, 59).unwrap(), "287082");
assert_eq!(totp_at(secret, 1111111109).unwrap(), "081804");
assert!(totp_code("JBSWY3DPEHPK3PXP").is_ok());
}
#[test]
fn totp_prompt_detection() {
assert!(is_totp_prompt("Verification code: "));
assert!(is_totp_prompt("Enter your 6-digit OTP"));
assert!(!is_totp_prompt("Password: "));
}
}
+262
View File
@@ -0,0 +1,262 @@
//! End-to-end SSH tests against a throwaway OpenSSH server run as the current user. Skipped
//! (passing) when `sshd`/`ssh-keygen` aren't installed.
use nexus_core::clients::Callbacks;
use nexus_core::clients::sftp;
use nexus_core::clients::ssh::{HostKeyCheck, SshClient};
use nexus_core::models::{ForwardDirection, JumpHost, PortForward, Profile, Protocol};
use std::io::{Read, Write};
use std::net::{TcpListener, TcpStream};
use std::path::{Path, PathBuf};
use std::process::{Child, Command};
use std::sync::{Arc, Mutex};
use std::time::{Duration, Instant};
struct Server {
dir: PathBuf,
port: u16,
child: Child,
agent: Option<Child>,
}
impl Drop for Server {
fn drop(&mut self) {
let _ = self.child.kill();
if let Some(a) = &mut self.agent {
let _ = a.kill();
}
let _ = std::fs::remove_dir_all(&self.dir);
}
}
fn which(p: &str) -> Option<PathBuf> {
["/usr/sbin", "/usr/bin", "/usr/local/sbin", "/usr/local/bin"].iter().map(|d| Path::new(d).join(p)).find(|p| p.exists())
}
fn free_port() -> u16 {
TcpListener::bind("127.0.0.1:0").unwrap().local_addr().unwrap().port()
}
fn start(name: &str) -> Option<Server> {
let sshd = which("sshd")?;
let keygen = which("ssh-keygen")?;
let sftp_server = ["/usr/lib/openssh/sftp-server", "/usr/libexec/openssh/sftp-server", "/usr/libexec/sftp-server"]
.iter()
.find(|p| Path::new(p).exists())?;
let dir = std::env::temp_dir().join(format!("nexus-sshd-{name}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
for k in ["host", "client"] {
Command::new(&keygen).args(["-q", "-t", "ed25519", "-N", "", "-f"]).arg(dir.join(k)).status().unwrap();
}
std::fs::copy(dir.join("client.pub"), dir.join("authorized_keys")).unwrap();
let port = free_port();
let cfg = format!(
"Port {port}\nListenAddress 127.0.0.1\nHostKey {d}/host\nAuthorizedKeysFile {d}/authorized_keys\nPidFile {d}/pid\n\
StrictModes no\nUsePAM no\nPasswordAuthentication no\nKbdInteractiveAuthentication no\nPubkeyAuthentication yes\n\
AllowTcpForwarding yes\nAllowAgentForwarding yes\nSubsystem sftp {sftp_server}\n",
d = dir.display()
);
std::fs::write(dir.join("sshd_config"), cfg).unwrap();
let child = Command::new(sshd).args(["-D", "-e", "-f"]).arg(dir.join("sshd_config")).spawn().ok()?;
let start = Instant::now();
while TcpStream::connect(("127.0.0.1", port)).is_err() {
if start.elapsed() > Duration::from_secs(5) {
return None;
}
std::thread::sleep(Duration::from_millis(50));
}
Some(Server { dir, port, child, agent: None })
}
fn profile(s: &Server) -> Profile {
let mut p = Profile::new(Protocol::Ssh);
p.host = "127.0.0.1".into();
p.port = s.port as i64;
p.username = std::env::var("USER").unwrap_or_else(|_| "root".into());
let c = p.ssh.as_mut().unwrap();
c.auth_methods = vec!["publickey".into()];
c.private_key_path = s.dir.join("client").to_string_lossy().into();
// Never start the user's login shell: profiles can auto-attach tmux/byobu to their real session.
c.startup_command = "/bin/sh -i".into();
p
}
type Seen = Arc<Mutex<Vec<u8>>>;
fn callbacks() -> (Callbacks, Seen) {
let seen: Seen = Arc::default();
let s2 = seen.clone();
let mut cb = Callbacks::noop();
cb.on_data = Arc::new(move |d| s2.lock().unwrap().extend_from_slice(d));
(cb, seen)
}
fn accept_all() -> (HostKeyCheck, Arc<Mutex<Vec<String>>>) {
let fps: Arc<Mutex<Vec<String>>> = Arc::default();
let f2 = fps.clone();
(Arc::new(move |i| {
f2.lock().unwrap().push(format!("{} {}", i.key_type, i.fingerprint));
Ok(())
}), fps)
}
fn wait_for(seen: &Seen, needle: &str) -> bool {
let start = Instant::now();
while start.elapsed() < Duration::from_secs(10) {
if String::from_utf8_lossy(&seen.lock().unwrap()).contains(needle) {
return true;
}
std::thread::sleep(Duration::from_millis(50));
}
false
}
fn echo_server() -> u16 {
let l = TcpListener::bind("127.0.0.1:0").unwrap();
let port = l.local_addr().unwrap().port();
std::thread::spawn(move || {
for s in l.incoming().flatten() {
std::thread::spawn(move || {
let mut s = s;
let mut buf = [0u8; 1024];
while let Ok(n) = s.read(&mut buf) {
if n == 0 || s.write_all(&buf[..n]).is_err() {
break;
}
}
});
}
});
port
}
fn echo_through(port: u16) -> String {
let start = Instant::now();
loop {
if let Ok(mut s) = TcpStream::connect(("127.0.0.1", port)) {
s.set_read_timeout(Some(Duration::from_secs(5))).unwrap();
s.write_all(b"ping").unwrap();
let mut buf = [0u8; 4];
if s.read_exact(&mut buf).is_ok() {
return String::from_utf8_lossy(&buf).into();
}
}
if start.elapsed() > Duration::from_secs(5) {
return String::new();
}
std::thread::sleep(Duration::from_millis(100));
}
}
#[test]
fn shell_exec_forwards_sftp() {
let Some(s) = start("main") else { return eprintln!("sshd not available; skipping") };
let (cb, seen) = callbacks();
let (check, fps) = accept_all();
let c = SshClient::new(&profile(&s), cb);
c.connect(check, None, None).unwrap();
assert!(fps.lock().unwrap()[0].starts_with("ssh-ed25519 SHA256:"));
c.resize(120, 40).unwrap();
c.write(b"echo nexus-$((40+2)) $COLUMNS\n").unwrap();
assert!(wait_for(&seen, "nexus-42"), "shell output: {}", String::from_utf8_lossy(&seen.lock().unwrap()));
assert_eq!(c.run_command("echo hi").unwrap(), "hi");
assert!(c.ping().unwrap() >= 0);
let echo = echo_server();
let lport = free_port();
let rule = PortForward { id: "l".into(), direction: ForwardDirection::Local, bind_port: lport as i64, dest_host: "127.0.0.1".into(), dest_port: echo as i64, enabled: true, ..Default::default() };
c.start_forward(rule).unwrap();
assert_eq!(echo_through(lport), "ping");
let rport = free_port();
let rule = PortForward { id: "r".into(), direction: ForwardDirection::Remote, bind_host: "127.0.0.1".into(), bind_port: rport as i64, dest_host: "127.0.0.1".into(), dest_port: echo as i64, enabled: true, ..Default::default() };
c.start_forward(rule).unwrap();
assert_eq!(echo_through(rport), "ping");
assert_eq!(c.active_forwards().len(), 2);
c.stop_forward("l").unwrap();
let start = Instant::now();
while TcpStream::connect(("127.0.0.1", lport)).is_ok() {
assert!(start.elapsed() < Duration::from_secs(2), "listener still open after stop");
std::thread::sleep(Duration::from_millis(20));
}
let sf = c.sftp().unwrap();
let local = s.dir.join("up.txt");
std::fs::write(&local, b"sftp payload").unwrap();
let remote = s.dir.join("remote.txt").to_string_lossy().to_string();
sftp::upload(&sf, &local, &remote, &|_| {}).unwrap();
let names: Vec<String> = sftp::list(&sf, &s.dir.to_string_lossy()).unwrap().into_iter().map(|e| e.name).collect();
assert!(names.contains(&"remote.txt".to_string()));
let back = s.dir.join("back.txt");
sftp::download(&sf, &remote, &back, &|_| {}).unwrap();
assert_eq!(std::fs::read(back).unwrap(), b"sftp payload");
assert_eq!(sftp::stat(&sf, &remote).unwrap().size, 12);
c.close();
}
#[test]
fn close_fires_once_and_remote_exit_closes() {
let Some(s) = start("close") else { return };
let (mut cb, _) = callbacks();
let n = Arc::new(Mutex::new(0));
let n2 = n.clone();
cb.on_close = Arc::new(move || *n2.lock().unwrap() += 1);
let c = SshClient::new(&profile(&s), cb);
c.connect(accept_all().0, None, None).unwrap();
c.write(b"exit\n").unwrap();
let start = Instant::now();
while *n.lock().unwrap() == 0 && start.elapsed() < Duration::from_secs(10) {
std::thread::sleep(Duration::from_millis(50));
}
c.close();
assert_eq!(*n.lock().unwrap(), 1);
}
#[test]
fn rejected_host_key_reports_reason() {
let Some(s) = start("reject") else { return };
let c = SshClient::new(&profile(&s), Callbacks::noop());
let err = c.connect(Arc::new(|_| Err("host key rejected by user".into())), None, None).unwrap_err();
assert!(err.to_string().contains("rejected by user"), "{err}");
}
#[test]
fn jump_host_chain() {
let Some(s) = start("jump") else { return };
let mut p = profile(&s);
let key = p.ssh.as_ref().unwrap().private_key_path.clone();
p.ssh.as_mut().unwrap().jump_hosts = vec![JumpHost { host: "127.0.0.1".into(), port: s.port as i64, username: p.username.clone(), private_key_path: key, ..Default::default() }];
let (check, fps) = accept_all();
let c = SshClient::new(&p, Callbacks::noop());
c.connect(check, None, None).unwrap();
assert_eq!(fps.lock().unwrap().len(), 2, "jump and target both verified");
assert_eq!(c.run_command("echo via-jump").unwrap(), "via-jump");
c.close();
}
#[test]
fn agent_auth_and_forwarding() {
let Some(mut s) = start("agent") else { return };
let (Some(agent), Some(add)) = (which("ssh-agent"), which("ssh-add")) else { return };
let sock = s.dir.join("agent.sock");
s.agent = Some(Command::new(agent).arg("-D").arg("-a").arg(&sock).spawn().unwrap());
let start = Instant::now();
while !sock.exists() && start.elapsed() < Duration::from_secs(5) {
std::thread::sleep(Duration::from_millis(50));
}
assert!(Command::new(add).arg(s.dir.join("client")).env("SSH_AUTH_SOCK", &sock).status().unwrap().success());
// SAFETY: tests in this file don't read SSH_AUTH_SOCK concurrently except through this client.
unsafe { std::env::set_var("SSH_AUTH_SOCK", &sock) };
let mut p = profile(&s);
let c = p.ssh.as_mut().unwrap();
c.private_key_path.clear();
c.agent_auth = true;
c.agent_forwarding = true;
let (cb, seen) = callbacks();
let cl = SshClient::new(&p, cb);
cl.connect(accept_all().0, None, None).unwrap();
cl.write(b"ssh-add -l >/dev/null && echo agent-$((1+1))-ok\n").unwrap();
assert!(wait_for(&seen, "agent-2-ok"), "forwarded agent not usable: {}", String::from_utf8_lossy(&seen.lock().unwrap()));
cl.close();
}