дзеркало
https://github.com/l0ng-ai/openidea.git
синхронізовано 2026-11-08 19:02:17 +02:00
Read Include, Match (host/user/exec), Key=Value, ProxyCommand with % tokens, StrictHostKeyChecking and UserKnownHostsFile from ~/.ssh/config, connect over a ProxyCommand's stdio, and try none authentication first in OpenSSH mode. accept-new records unknown host keys and still refuses changed ones; Match exec runs off the async runtime with a 10 s limit.
698 рядки
26 KiB
Rust
698 рядки
26 KiB
Rust
//! An authenticated SSH connection (russh), optionally through `ProxyJump`
|
|
//! hosts or a `ProxyCommand`, and what runs over it: commands, processes, SFTP
|
|
//! and TCP channels.
|
|
//!
|
|
//! Host keys are checked against `known_hosts`; an unknown key fails the
|
|
//! connection with [`ConnectError::UnknownHostKey`] so the UI can ask the user
|
|
//! (or is recorded silently under `StrictHostKeyChecking accept-new`), and a
|
|
//! changed key is refused unless host key checking is off.
|
|
|
|
use crate::target::{Hop, HostKeyPolicy, SshFiles, SshTarget, expand, home, resolve_hops};
|
|
use ide_core::remote_settings::SshAuth;
|
|
use ide_core::{IdeError, IdeResult, error::ErrorKind};
|
|
use parking_lot::Mutex;
|
|
use russh::client::{self, Handle, Msg};
|
|
use russh::keys::{self, HashAlg, PrivateKeyWithHashAlg, PublicKey, PublicKeyOrCertificate, known_hosts};
|
|
use russh::{ChannelMsg, ChannelStream, ChannelWriteHalf, Sig};
|
|
use std::path::{Path, PathBuf};
|
|
use std::sync::Arc;
|
|
use std::time::Duration;
|
|
use tokio::sync::oneshot;
|
|
|
|
/// Generous: a relayed VPN path (e.g. Tailscale via DERP) can take ~1 s per round trip.
|
|
const CONNECT_TIMEOUT: Duration = Duration::from_secs(45);
|
|
const DEFAULT_IDENTITIES: [&str; 3] = ["id_ed25519", "id_ecdsa", "id_rsa"];
|
|
|
|
/// A host key the user has not trusted yet.
|
|
#[derive(Debug, Clone)]
|
|
pub struct UnknownHostKey {
|
|
pub host: String,
|
|
pub port: u16,
|
|
pub key: PublicKey,
|
|
pub known_hosts: Option<PathBuf>,
|
|
}
|
|
|
|
impl UnknownHostKey {
|
|
/// `ED25519 SHA256:…`, as OpenSSH prints it.
|
|
pub fn fingerprint(&self) -> String {
|
|
format!("{} {}", self.key.algorithm().as_str(), self.key.fingerprint(HashAlg::Sha256))
|
|
}
|
|
|
|
/// Records the key in `known_hosts` (blocking).
|
|
pub fn trust(&self, known_hosts: &Path) -> IdeResult<()> {
|
|
let known_hosts = self.known_hosts.as_deref().unwrap_or(known_hosts);
|
|
if let Some(dir) = known_hosts.parent() {
|
|
std::fs::create_dir_all(dir)?;
|
|
}
|
|
known_hosts::learn_known_hosts_path(&self.host, self.port, &self.key, known_hosts)
|
|
.map_err(|e| IdeError::new(ErrorKind::Io, format!("cannot update {}: {e}", known_hosts.display())))
|
|
}
|
|
|
|
/// The error shown for it; the UI recognises the `Unknown SSH host key` prefix.
|
|
pub fn error(&self) -> IdeError {
|
|
IdeError::new(
|
|
ErrorKind::Protocol,
|
|
format!("Unknown SSH host key for {}:{} ({})", self.host, self.port, self.fingerprint()),
|
|
)
|
|
}
|
|
}
|
|
|
|
#[derive(Debug)]
|
|
pub enum ConnectError {
|
|
UnknownHostKey(Box<UnknownHostKey>),
|
|
Other(IdeError),
|
|
}
|
|
|
|
impl From<IdeError> for ConnectError {
|
|
fn from(e: IdeError) -> Self {
|
|
ConnectError::Other(e)
|
|
}
|
|
}
|
|
|
|
impl From<ConnectError> for IdeError {
|
|
fn from(e: ConnectError) -> Self {
|
|
match e {
|
|
ConnectError::UnknownHostKey(k) => k.error(),
|
|
ConnectError::Other(e) => e,
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Output of a finished remote command.
|
|
#[derive(Debug, Clone, Default)]
|
|
pub struct ExecOutput {
|
|
/// `None` when the server reported no status (killed by a signal).
|
|
pub code: Option<u32>,
|
|
pub stdout: Vec<u8>,
|
|
pub stderr: Vec<u8>,
|
|
}
|
|
|
|
impl ExecOutput {
|
|
pub fn success(&self) -> bool {
|
|
self.code == Some(0)
|
|
}
|
|
|
|
pub fn stdout_str(&self) -> String {
|
|
String::from_utf8_lossy(&self.stdout).into_owned()
|
|
}
|
|
|
|
/// stdout on success, else an error with stderr.
|
|
pub fn into_stdout(self, what: &str) -> IdeResult<String> {
|
|
if self.success() {
|
|
return Ok(self.stdout_str());
|
|
}
|
|
let err = String::from_utf8_lossy(&self.stderr).trim().to_owned();
|
|
let code = self.code.map_or_else(|| "killed".to_owned(), |c| format!("exit code {c}"));
|
|
Err(io(if err.is_empty() { format!("{what} failed ({code})") } else { format!("{what} failed: {err}") }))
|
|
}
|
|
}
|
|
|
|
/// An open SSH connection; dropping it closes the sessions.
|
|
pub struct Session {
|
|
handle: Handle<Client>,
|
|
/// Sessions of the `ProxyJump` hosts, kept alive under `handle`.
|
|
_jumps: Vec<Handle<Client>>,
|
|
label: String,
|
|
}
|
|
|
|
impl Session {
|
|
/// Connects and authenticates; `secret` is the password or key passphrase.
|
|
pub async fn connect(target: &SshTarget, secret: Option<&str>, files: &SshFiles) -> Result<Session, ConnectError> {
|
|
let host = format!("{}:{}", target.host, target.port.unwrap_or(22));
|
|
match tokio::time::timeout(CONNECT_TIMEOUT, Self::connect_inner(target, secret, files)).await {
|
|
Ok(r) => r,
|
|
Err(_) => {
|
|
Err(io(format!("timed out connecting to the SSH host {host} ({} s)", CONNECT_TIMEOUT.as_secs())).into())
|
|
}
|
|
}
|
|
}
|
|
|
|
async fn connect_inner(
|
|
target: &SshTarget,
|
|
secret: Option<&str>,
|
|
files: &SshFiles,
|
|
) -> Result<Session, ConnectError> {
|
|
// Resolving may run `Match exec` commands from the config.
|
|
let hops = {
|
|
let (target, config) = (target.clone(), files.config.clone());
|
|
tokio::task::spawn_blocking(move || resolve_hops(&target, config.as_deref()))
|
|
.await
|
|
.map_err(|e| io(format!("cannot read the SSH config: {e}")))??
|
|
};
|
|
let mut sessions: Vec<Handle<Client>> = Vec::new();
|
|
for (i, hop) in hops.iter().enumerate() {
|
|
let last = i == hops.len() - 1;
|
|
let mut handle = match sessions.last() {
|
|
None => connect_first_hop(hop, &files.known_hosts).await?,
|
|
Some(prev) => {
|
|
let channel = prev
|
|
.channel_open_direct_tcpip(hop.host.as_str(), u32::from(hop.port), "127.0.0.1", 0)
|
|
.await
|
|
.map_err(|e| io(format!("cannot reach {} through the jump host: {e}", hop.label())))?;
|
|
connect(hop, &files.known_hosts, Some(channel.into_stream())).await?
|
|
}
|
|
};
|
|
// Jump hosts always use the agent / identity files; the secret belongs to the target.
|
|
let mode = if last { target.auth } else { SshAuth::OpenSsh };
|
|
authenticate(&mut handle, hop, mode, &target.key_file, secret).await?;
|
|
sessions.push(handle);
|
|
}
|
|
let handle = sessions.pop().expect("at least one hop");
|
|
let label = hops.last().expect("at least one hop").label();
|
|
Ok(Session { handle, _jumps: sessions, label })
|
|
}
|
|
|
|
/// `user@host:port` of the target.
|
|
pub fn label(&self) -> &str {
|
|
&self.label
|
|
}
|
|
|
|
/// The connection ended (network loss, server restart).
|
|
pub fn is_closed(&self) -> bool {
|
|
self.handle.is_closed()
|
|
}
|
|
|
|
/// A TCP connection to `host:port` as seen from the SSH host.
|
|
pub async fn direct_tcpip(&self, host: &str, port: u16) -> Result<ChannelStream<Msg>, russh::Error> {
|
|
let channel = self.handle.channel_open_direct_tcpip(host, u32::from(port), "127.0.0.1", 0).await?;
|
|
Ok(channel.into_stream())
|
|
}
|
|
|
|
/// Starts `command` and returns its stdin / stdout as one byte stream (stderr is dropped).
|
|
pub async fn exec_stream(&self, command: &str) -> Result<ChannelStream<Msg>, russh::Error> {
|
|
let channel = self.handle.channel_open_session().await?;
|
|
channel.exec(true, command).await?;
|
|
Ok(channel.into_stream())
|
|
}
|
|
|
|
/// Runs `command` through the remote shell and collects its output.
|
|
pub async fn exec(&self, command: &str, stdin: Option<&[u8]>) -> IdeResult<ExecOutput> {
|
|
let mut channel = self.handle.channel_open_session().await.map_err(|e| self.failed(e))?;
|
|
channel.exec(true, command).await.map_err(|e| self.failed(e))?;
|
|
if let Some(input) = stdin {
|
|
channel.data(input).await.map_err(|e| self.failed(e))?;
|
|
}
|
|
channel.eof().await.map_err(|e| self.failed(e))?;
|
|
let mut out = ExecOutput::default();
|
|
while let Some(msg) = channel.wait().await {
|
|
match msg {
|
|
ChannelMsg::Data { data } => out.stdout.extend_from_slice(&data),
|
|
ChannelMsg::ExtendedData { data, ext: 1 } => out.stderr.extend_from_slice(&data),
|
|
ChannelMsg::ExitStatus { exit_status } => out.code = Some(exit_status),
|
|
_ => {}
|
|
}
|
|
}
|
|
Ok(out)
|
|
}
|
|
|
|
/// Starts `command` (through the remote shell), streaming its output.
|
|
/// With `pty` (columns, rows) stdout and stderr arrive merged, as in a terminal.
|
|
pub async fn spawn(
|
|
&self,
|
|
command: &str,
|
|
pty: Option<(u16, u16)>,
|
|
on_output: Box<dyn FnMut(Output<'_>) + Send>,
|
|
) -> IdeResult<RemoteProcess> {
|
|
let channel = self.handle.channel_open_session().await.map_err(|e| self.failed(e))?;
|
|
if let Some((cols, rows)) = pty {
|
|
channel
|
|
.request_pty(true, "xterm-256color", u32::from(cols), u32::from(rows), 0, 0, &[])
|
|
.await
|
|
.map_err(|e| self.failed(e))?;
|
|
}
|
|
if command.is_empty() {
|
|
channel.request_shell(true).await.map_err(|e| self.failed(e))?;
|
|
} else {
|
|
channel.exec(true, command).await.map_err(|e| self.failed(e))?;
|
|
}
|
|
let (mut read, write) = channel.split();
|
|
let (tx, rx) = oneshot::channel();
|
|
let mut on_output = on_output;
|
|
tokio::spawn(async move {
|
|
let mut code = None;
|
|
while let Some(msg) = read.wait().await {
|
|
match msg {
|
|
ChannelMsg::Data { data } => on_output(Output::Stdout(&data)),
|
|
ChannelMsg::ExtendedData { data, ext: 1 } => on_output(Output::Stderr(&data)),
|
|
ChannelMsg::ExitStatus { exit_status } => code = Some(exit_status),
|
|
_ => {}
|
|
}
|
|
}
|
|
let _ = tx.send(code);
|
|
});
|
|
Ok(RemoteProcess { write: Arc::new(write), exit: Mutex::new(Some(rx)) })
|
|
}
|
|
|
|
/// Writes `data` to `remote` (absolute; its folder is created) through an
|
|
/// exec channel, gzip-compressed. Much faster than SFTP for one big file:
|
|
/// SFTP writes wait for each packet's reply, the channel only for its window.
|
|
pub async fn put(&self, remote: &str, data: &[u8]) -> IdeResult<()> {
|
|
self.put_with_progress(remote, data, &|_| {}).await
|
|
}
|
|
|
|
/// [`Self::put`], reporting the fraction sent (0..1) as it goes.
|
|
pub async fn put_with_progress(
|
|
&self,
|
|
remote: &str,
|
|
data: &[u8],
|
|
progress: &(dyn Fn(f64) + Send + Sync),
|
|
) -> IdeResult<()> {
|
|
use std::io::Write as _;
|
|
let mut gz = flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::fast());
|
|
gz.write_all(data)?;
|
|
let packed = gz.finish()?;
|
|
let q = crate::shell::quote;
|
|
let dir = crate::sftp::parent(remote).unwrap_or("/");
|
|
let command = format!("mkdir -p {} && gzip -dc > {}", q(dir), q(remote));
|
|
let stderr = Arc::new(Mutex::new(Vec::new()));
|
|
let sink = stderr.clone();
|
|
let process = self
|
|
.spawn(
|
|
&command,
|
|
None,
|
|
Box::new(move |out| {
|
|
if let Output::Stderr(b) = out {
|
|
sink.lock().extend_from_slice(b);
|
|
}
|
|
}),
|
|
)
|
|
.await?;
|
|
let failed = |code: Option<u32>| {
|
|
let err = String::from_utf8_lossy(&stderr.lock()).trim().to_owned();
|
|
io(format!("cannot write {remote} on {} ({code:?}): {err}", self.label))
|
|
};
|
|
// A command that exits early (no gzip, a folder it cannot write) stops reading: a
|
|
// write would wait for channel window forever, so each one races the exit.
|
|
let mut exit = std::pin::pin!(process.wait());
|
|
let mut sent = 0;
|
|
for chunk in packed.chunks(256 * 1024) {
|
|
tokio::select! {
|
|
written = process.write(chunk) => written?,
|
|
code = &mut exit => return Err(failed(code)),
|
|
}
|
|
sent += chunk.len();
|
|
progress(sent as f64 / packed.len().max(1) as f64);
|
|
}
|
|
tokio::select! {
|
|
closed = process.eof() => closed?,
|
|
code = &mut exit => return Err(failed(code)),
|
|
}
|
|
match exit.await {
|
|
Some(0) => Ok(()),
|
|
code => Err(failed(code)),
|
|
}
|
|
}
|
|
|
|
/// An SFTP session on its own channel.
|
|
pub async fn sftp(&self) -> IdeResult<russh_sftp::client::SftpSession> {
|
|
let channel = self.handle.channel_open_session().await.map_err(|e| self.failed(e))?;
|
|
channel.request_subsystem(true, "sftp").await.map_err(|e| self.failed(e))?;
|
|
russh_sftp::client::SftpSession::new(channel.into_stream())
|
|
.await
|
|
.map_err(|e| io(format!("SFTP is not available on {}: {e}", self.label)))
|
|
}
|
|
|
|
fn failed(&self, e: russh::Error) -> IdeError {
|
|
io(format!("SSH {}: {e}", self.label))
|
|
}
|
|
}
|
|
|
|
/// A chunk of a remote process's output.
|
|
pub enum Output<'a> {
|
|
Stdout(&'a [u8]),
|
|
Stderr(&'a [u8]),
|
|
}
|
|
|
|
/// A running remote command.
|
|
pub struct RemoteProcess {
|
|
write: Arc<ChannelWriteHalf<Msg>>,
|
|
exit: Mutex<Option<oneshot::Receiver<Option<u32>>>>,
|
|
}
|
|
|
|
impl RemoteProcess {
|
|
pub async fn write(&self, data: &[u8]) -> IdeResult<()> {
|
|
self.write.data(data).await.map_err(|e| io(e.to_string()))
|
|
}
|
|
|
|
/// Closes stdin.
|
|
pub async fn eof(&self) -> IdeResult<()> {
|
|
self.write.eof().await.map_err(|e| io(e.to_string()))
|
|
}
|
|
|
|
pub async fn resize(&self, cols: u16, rows: u16) -> IdeResult<()> {
|
|
self.write.window_change(u32::from(cols), u32::from(rows), 0, 0).await.map_err(|e| io(e.to_string()))
|
|
}
|
|
|
|
/// Sends `INT` / `TERM` / `KILL`; servers that ignore signals get the channel closed.
|
|
pub async fn signal(&self, signal: Sig) -> IdeResult<()> {
|
|
let kill = matches!(signal, Sig::KILL);
|
|
let r = self.write.signal(signal).await;
|
|
if kill || r.is_err() {
|
|
let _ = self.write.close().await;
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
/// Waits for the exit status (`None`: killed, or no status reported). Once.
|
|
pub async fn wait(&self) -> Option<u32> {
|
|
let rx = self.exit.lock().take()?;
|
|
rx.await.ok().flatten()
|
|
}
|
|
}
|
|
|
|
fn io(message: impl Into<String>) -> IdeError {
|
|
IdeError::new(ErrorKind::Io, message)
|
|
}
|
|
|
|
// --- connection ----------------------------------------------------------------
|
|
|
|
#[derive(Debug)]
|
|
enum HostKeyIssue {
|
|
Unknown(PublicKey),
|
|
Changed(usize),
|
|
}
|
|
|
|
pub struct Client {
|
|
host: String,
|
|
port: u16,
|
|
known_hosts: PathBuf,
|
|
host_key_policy: HostKeyPolicy,
|
|
issue: Arc<Mutex<Option<HostKeyIssue>>>,
|
|
}
|
|
|
|
impl client::Handler for Client {
|
|
type Error = russh::Error;
|
|
|
|
async fn check_server_key(&mut self, key: &PublicKeyOrCertificate) -> Result<bool, Self::Error> {
|
|
if self.host_key_policy == HostKeyPolicy::Off {
|
|
return Ok(true);
|
|
}
|
|
let key = match key {
|
|
PublicKeyOrCertificate::PublicKey { key, .. } => key.clone(),
|
|
PublicKeyOrCertificate::Certificate(cert) => PublicKey::from(cert.public_key().clone()),
|
|
};
|
|
let issue = match known_hosts::check_known_hosts_path(&self.host, self.port, &key, &self.known_hosts) {
|
|
Ok(true) => return Ok(true),
|
|
Err(keys::Error::KeyChanged { line }) => HostKeyIssue::Changed(line),
|
|
Ok(false) | Err(_) if self.host_key_policy == HostKeyPolicy::AcceptNew && self.learn(&key) => {
|
|
return Ok(true);
|
|
}
|
|
Ok(false) | Err(_) => HostKeyIssue::Unknown(key),
|
|
};
|
|
*self.issue.lock() = Some(issue);
|
|
Ok(false)
|
|
}
|
|
}
|
|
|
|
impl Client {
|
|
fn learn(&self, key: &PublicKey) -> bool {
|
|
if let Some(dir) = self.known_hosts.parent() {
|
|
let _ = std::fs::create_dir_all(dir);
|
|
}
|
|
known_hosts::learn_known_hosts_path(&self.host, self.port, key, &self.known_hosts).is_ok()
|
|
}
|
|
}
|
|
|
|
struct ProxyStream {
|
|
child: tokio::process::Child,
|
|
stdin: Option<tokio::process::ChildStdin>,
|
|
stdout: Option<tokio::process::ChildStdout>,
|
|
stderr: Arc<Mutex<Vec<u8>>>,
|
|
}
|
|
|
|
impl Drop for ProxyStream {
|
|
fn drop(&mut self) {
|
|
if let Some(pid) = self.child.id() {
|
|
ide_core::process::kill_tree_of(pid);
|
|
}
|
|
}
|
|
}
|
|
|
|
impl tokio::io::AsyncRead for ProxyStream {
|
|
fn poll_read(
|
|
mut self: std::pin::Pin<&mut Self>,
|
|
cx: &mut std::task::Context<'_>,
|
|
buf: &mut tokio::io::ReadBuf<'_>,
|
|
) -> std::task::Poll<std::io::Result<()>> {
|
|
match self.stdout.as_mut() {
|
|
Some(stdout) => std::pin::Pin::new(stdout).poll_read(cx, buf),
|
|
None => std::task::Poll::Ready(Ok(())),
|
|
}
|
|
}
|
|
}
|
|
|
|
impl tokio::io::AsyncWrite for ProxyStream {
|
|
fn poll_write(
|
|
mut self: std::pin::Pin<&mut Self>,
|
|
cx: &mut std::task::Context<'_>,
|
|
buf: &[u8],
|
|
) -> std::task::Poll<std::io::Result<usize>> {
|
|
match self.stdin.as_mut() {
|
|
Some(stdin) => std::pin::Pin::new(stdin).poll_write(cx, buf),
|
|
None => std::task::Poll::Ready(Ok(0)),
|
|
}
|
|
}
|
|
|
|
fn poll_flush(
|
|
mut self: std::pin::Pin<&mut Self>,
|
|
cx: &mut std::task::Context<'_>,
|
|
) -> std::task::Poll<std::io::Result<()>> {
|
|
match self.stdin.as_mut() {
|
|
Some(stdin) => std::pin::Pin::new(stdin).poll_flush(cx),
|
|
None => std::task::Poll::Ready(Ok(())),
|
|
}
|
|
}
|
|
|
|
fn poll_shutdown(
|
|
mut self: std::pin::Pin<&mut Self>,
|
|
cx: &mut std::task::Context<'_>,
|
|
) -> std::task::Poll<std::io::Result<()>> {
|
|
if let Some(stdin) = self.stdin.as_mut()
|
|
&& let std::task::Poll::Pending = std::pin::Pin::new(stdin).poll_shutdown(cx)
|
|
{
|
|
return std::task::Poll::Pending;
|
|
}
|
|
self.stdin = None;
|
|
std::task::Poll::Ready(Ok(()))
|
|
}
|
|
}
|
|
|
|
fn spawn_proxy_command(command: &str) -> Result<ProxyStream, std::io::Error> {
|
|
#[cfg(unix)]
|
|
let mut cmd = {
|
|
let mut c = tokio::process::Command::new("/bin/sh");
|
|
c.arg("-c").arg(command);
|
|
c
|
|
};
|
|
#[cfg(windows)]
|
|
let mut cmd = {
|
|
let mut c = tokio::process::Command::new("cmd");
|
|
c.arg("/C").arg(command);
|
|
c
|
|
};
|
|
ide_core::process::prepare(cmd.as_std_mut());
|
|
ide_core::process::own_group(cmd.as_std_mut());
|
|
cmd.stdin(std::process::Stdio::piped())
|
|
.stdout(std::process::Stdio::piped())
|
|
.stderr(std::process::Stdio::piped())
|
|
.kill_on_drop(true);
|
|
let mut child = cmd.spawn()?;
|
|
let stdin = child.stdin.take();
|
|
let stdout = child.stdout.take();
|
|
let stderr_buf = Arc::new(Mutex::new(Vec::new()));
|
|
if let Some(mut stderr) = child.stderr.take() {
|
|
let sink = stderr_buf.clone();
|
|
tokio::spawn(async move {
|
|
use tokio::io::AsyncReadExt;
|
|
let mut chunk = [0u8; 1024];
|
|
while let Ok(n) = stderr.read(&mut chunk).await {
|
|
if n == 0 {
|
|
break;
|
|
}
|
|
let mut g = sink.lock();
|
|
if g.len() < 4096 {
|
|
let take = (4096 - g.len()).min(n);
|
|
g.extend_from_slice(&chunk[..take]);
|
|
}
|
|
}
|
|
});
|
|
}
|
|
Ok(ProxyStream { child, stdin, stdout, stderr: stderr_buf })
|
|
}
|
|
|
|
async fn connect_first_hop(hop: &Hop, known_hosts: &Path) -> Result<Handle<Client>, ConnectError> {
|
|
if let Some(command) = hop.proxy_command.as_deref() {
|
|
let stream = spawn_proxy_command(command).map_err(|e| {
|
|
ConnectError::Other(io(format!("cannot start ProxyCommand for {}:{}: {e}", hop.host, hop.port)))
|
|
})?;
|
|
let stderr = stream.stderr.clone();
|
|
connect(hop, known_hosts, Some(stream)).await.map_err(|e| match e {
|
|
ConnectError::Other(mut err) => {
|
|
let detail = String::from_utf8_lossy(&stderr.lock()).trim().to_owned();
|
|
if !detail.is_empty() {
|
|
err.message = format!("{}: {detail}", err.message);
|
|
}
|
|
ConnectError::Other(err)
|
|
}
|
|
other => other,
|
|
})
|
|
} else {
|
|
connect::<tokio::net::TcpStream>(hop, known_hosts, None).await
|
|
}
|
|
}
|
|
|
|
async fn connect<S>(hop: &Hop, known_hosts: &Path, stream: Option<S>) -> Result<Handle<Client>, ConnectError>
|
|
where
|
|
S: tokio::io::AsyncRead + tokio::io::AsyncWrite + Unpin + Send + 'static,
|
|
{
|
|
let config = Arc::new(client::Config {
|
|
inactivity_timeout: None,
|
|
keepalive_interval: Some(Duration::from_secs(30)),
|
|
keepalive_max: 3,
|
|
nodelay: true,
|
|
..Default::default()
|
|
});
|
|
let issue = Arc::new(Mutex::new(None));
|
|
let effective_known_hosts = hop.known_hosts.as_deref().unwrap_or(known_hosts).to_owned();
|
|
let handler = Client {
|
|
host: hop.host.clone(),
|
|
port: hop.port,
|
|
known_hosts: effective_known_hosts,
|
|
host_key_policy: hop.host_key_policy,
|
|
issue: issue.clone(),
|
|
};
|
|
let result = match stream {
|
|
None => client::connect(config, (hop.host.as_str(), hop.port), handler).await,
|
|
Some(s) => client::connect_stream(config, s, handler).await,
|
|
};
|
|
result.map_err(|e| match issue.lock().take() {
|
|
Some(HostKeyIssue::Unknown(key)) => ConnectError::UnknownHostKey(Box::new(UnknownHostKey {
|
|
host: hop.host.clone(),
|
|
port: hop.port,
|
|
key,
|
|
known_hosts: hop.known_hosts.clone(),
|
|
})),
|
|
Some(HostKeyIssue::Changed(line)) => ConnectError::Other(IdeError::new(
|
|
ErrorKind::PermissionDenied,
|
|
format!(
|
|
"SSH host key of {}:{} has changed (known_hosts line {line}); possible man-in-the-middle attack",
|
|
hop.host, hop.port
|
|
),
|
|
)),
|
|
None => ConnectError::Other(io(format!("cannot connect to SSH host {}:{}: {e}", hop.host, hop.port))),
|
|
})
|
|
}
|
|
|
|
// --- authentication ------------------------------------------------------------
|
|
|
|
fn denied(message: String) -> IdeError {
|
|
IdeError::new(ErrorKind::PermissionDenied, message)
|
|
}
|
|
|
|
async fn rsa_hash(handle: &Handle<Client>) -> Option<HashAlg> {
|
|
handle.best_supported_rsa_hash().await.ok().flatten().flatten()
|
|
}
|
|
|
|
async fn with_key(handle: &mut Handle<Client>, user: &str, key: keys::PrivateKey) -> IdeResult<bool> {
|
|
let key = PrivateKeyWithHashAlg::new(Arc::new(key), rsa_hash(handle).await);
|
|
let r = handle.authenticate_publickey(user, key).await.map_err(|e| io(e.to_string()))?;
|
|
Ok(r.success())
|
|
}
|
|
|
|
/// Every identity of the running SSH agent, until one is accepted.
|
|
async fn with_agent(handle: &mut Handle<Client>, user: &str) -> bool {
|
|
#[cfg(unix)]
|
|
let agent = keys::agent::client::AgentClient::connect_env().await;
|
|
#[cfg(windows)]
|
|
let agent = keys::agent::client::AgentClient::connect_named_pipe(r"\\.\pipe\openssh-ssh-agent").await;
|
|
let Ok(mut agent) = agent else { return false };
|
|
let Ok(identities) = agent.request_identities().await else { return false };
|
|
let hash = rsa_hash(handle).await;
|
|
for id in identities {
|
|
let ok = match id {
|
|
keys::agent::AgentIdentity::PublicKey { key, .. } => {
|
|
handle.authenticate_publickey_with(user, key, hash, &mut agent).await.map(|r| r.success())
|
|
}
|
|
keys::agent::AgentIdentity::Certificate { certificate, .. } => {
|
|
handle.authenticate_certificate_with(user, certificate, hash, &mut agent).await.map(|r| r.success())
|
|
}
|
|
};
|
|
if ok.unwrap_or(false) {
|
|
return true;
|
|
}
|
|
}
|
|
false
|
|
}
|
|
|
|
async fn authenticate(
|
|
handle: &mut Handle<Client>,
|
|
hop: &Hop,
|
|
mode: SshAuth,
|
|
key_file: &str,
|
|
secret: Option<&str>,
|
|
) -> IdeResult<()> {
|
|
let user = hop.user.as_str();
|
|
let failed = || denied(format!("SSH authentication failed for {}", hop.label()));
|
|
match mode {
|
|
SshAuth::Password => {
|
|
let Some(password) = secret else {
|
|
return Err(denied(format!("SSH password required for {}", hop.label())));
|
|
};
|
|
let r = handle.authenticate_password(user, password).await.map_err(|e| io(e.to_string()))?;
|
|
if r.success() { Ok(()) } else { Err(failed()) }
|
|
}
|
|
SshAuth::KeyPair => {
|
|
if key_file.trim().is_empty() {
|
|
return Err(IdeError::invalid("SSH private key file is not set"));
|
|
}
|
|
let path = expand(key_file.trim());
|
|
let key = load_key(&path, secret)?;
|
|
if with_key(handle, user, key).await? { Ok(()) } else { Err(failed()) }
|
|
}
|
|
SshAuth::OpenSsh => {
|
|
if handle.authenticate_none(user).await.map(|r| r.success()).unwrap_or(false) {
|
|
return Ok(());
|
|
}
|
|
if with_agent(handle, user).await {
|
|
return Ok(());
|
|
}
|
|
let files: Vec<PathBuf> = if hop.identity_files.is_empty() {
|
|
home().map(|h| DEFAULT_IDENTITIES.iter().map(|n| h.join(".ssh").join(n)).collect()).unwrap_or_default()
|
|
} else {
|
|
hop.identity_files.iter().map(|p| expand(&p.to_string_lossy())).collect()
|
|
};
|
|
let mut locked: Option<PathBuf> = None;
|
|
for path in files.iter().filter(|p| p.is_file()) {
|
|
match load_key(path, secret) {
|
|
Ok(key) => {
|
|
if with_key(handle, user, key).await? {
|
|
return Ok(());
|
|
}
|
|
}
|
|
Err(e) if e.kind == ErrorKind::PermissionDenied => locked = locked.or(Some(path.clone())),
|
|
Err(e) => tracing::warn!("SSH key {}: {}", path.display(), e.message),
|
|
}
|
|
}
|
|
match locked {
|
|
// An encrypted key could not be tried: ask for its passphrase.
|
|
Some(path) if secret.is_none() => {
|
|
Err(denied(format!("SSH key passphrase required for {}", path.display())))
|
|
}
|
|
_ => Err(failed()),
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Loads a private key; an encrypted key without (or with a wrong) passphrase is `PermissionDenied`.
|
|
fn load_key(path: &Path, passphrase: Option<&str>) -> IdeResult<keys::PrivateKey> {
|
|
match keys::load_secret_key(path, passphrase) {
|
|
Ok(k) => Ok(k),
|
|
Err(keys::Error::KeyIsEncrypted) => Err(denied(format!("SSH key passphrase required for {}", path.display()))),
|
|
Err(e) if passphrase.is_some() => {
|
|
Err(denied(format!("cannot decrypt SSH key {} (wrong passphrase?): {e}", path.display())))
|
|
}
|
|
Err(e) => Err(IdeError::invalid(format!("cannot read SSH key {}: {e}", path.display()))),
|
|
}
|
|
}
|