用户的堡垒机在同一条连接上又来一个通道(系统探测的 exec)时会结束整个会 话:正替终端登录目标机时到来,终端报「No more authentication methods available」;探测跑完通道一关,刚出提示符就退出。Xshell 和 OpenSSH 只开终 端一个通道。HostLogin 加 shell_only(临时主机置上):不做系统探测,工具的 命令直接拒绝、不发给服务器,右侧栏只给不执行命令的命令片段。延迟测量是全局 请求,照常。 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1301 lines
47 KiB
Rust
1301 lines
47 KiB
Rust
//! SSH connection and authentication shared by terminals and SFTP.
|
||
use crate::{
|
||
connection::{
|
||
ConnectionPrompt, ConnectionPromptField, ConnectionPromptKind, ConnectionPromptReply,
|
||
},
|
||
host::{HostLogin, JumpLogin, LoginMethod, LoginRoute, ProxyLogin},
|
||
secrets::{SecretRef, SharedSecretStore},
|
||
};
|
||
use anyhow::{Context as _, Result, anyhow, bail};
|
||
use russh::keys::{
|
||
HashAlg, PrivateKeyWithHashAlg, PublicKey,
|
||
known_hosts::{known_host_keys_path, learn_known_hosts_path},
|
||
load_secret_key, parse_public_key_base64,
|
||
};
|
||
use russh::{
|
||
MethodKind, MethodSet,
|
||
client::{self, AuthResult, KeyboardInteractiveAuthResponse},
|
||
};
|
||
use std::{
|
||
collections::HashMap,
|
||
future::Future,
|
||
path::{Path, PathBuf},
|
||
sync::{
|
||
Arc, Mutex, MutexGuard,
|
||
atomic::{AtomicU64, Ordering},
|
||
},
|
||
time::Duration,
|
||
};
|
||
use tokio::io::{AsyncRead, AsyncWrite};
|
||
use tokio::net::TcpStream;
|
||
use tokio::sync::{mpsc, oneshot, watch};
|
||
use zeroize::Zeroizing;
|
||
|
||
use super::proxy::{ProxyAuth, handshake};
|
||
use super::tester::describe_login_error;
|
||
const CONNECT_TIMEOUT: Duration = Duration::from_secs(15);
|
||
const KEEPALIVE_INTERVAL: Duration = Duration::from_secs(30);
|
||
const AUTH_RETRIES: usize = 3;
|
||
/// How long to wait for the agent to answer the door. A named pipe that
|
||
/// stays busy would otherwise be retried forever.
|
||
const AGENT_TIMEOUT: Duration = Duration::from_secs(5);
|
||
const CONNECT_FAILED: &str = "无法建立 SSH 连接,请检查地址、端口和主机密钥";
|
||
static NEXT_PROMPT_ID: AtomicU64 = AtomicU64::new(1);
|
||
|
||
/// What one connection logs in with: a host's login as the store resolved
|
||
/// it on the UI thread, credential and all.
|
||
#[derive(Clone)]
|
||
pub struct SshConnectionConfig {
|
||
login: HostLogin,
|
||
}
|
||
|
||
impl From<&HostLogin> for SshConnectionConfig {
|
||
fn from(login: &HostLogin) -> Self {
|
||
Self {
|
||
login: login.clone(),
|
||
}
|
||
}
|
||
}
|
||
|
||
impl SshConnectionConfig {
|
||
/// See [`HostLogin::shell_only`].
|
||
pub(crate) fn shell_only(&self) -> bool {
|
||
self.login.shell_only
|
||
}
|
||
}
|
||
|
||
/// Where the SSH agent listens.
|
||
#[derive(Clone, Debug, Default, PartialEq, Eq)]
|
||
pub enum AgentLocation {
|
||
/// The user's own agent: the socket `SSH_AUTH_SOCK` names on macOS and
|
||
/// Linux, the OpenSSH agent service's pipe on Windows.
|
||
#[default]
|
||
System,
|
||
/// An agent at this socket or pipe. Tests use it rather than change the
|
||
/// process's environment under other tests.
|
||
At(PathBuf),
|
||
}
|
||
|
||
/// Cloneable trust and credential service. Every clone shares the trust-file lock.
|
||
#[derive(Clone)]
|
||
pub struct SshConnector {
|
||
known_hosts_path: PathBuf,
|
||
known_hosts_lock: Arc<Mutex<()>>,
|
||
secrets: SharedSecretStore,
|
||
agent: AgentLocation,
|
||
}
|
||
impl SshConnector {
|
||
pub fn new(path: impl Into<PathBuf>, secrets: SharedSecretStore) -> Self {
|
||
Self {
|
||
known_hosts_path: path.into(),
|
||
known_hosts_lock: Arc::new(Mutex::new(())),
|
||
secrets,
|
||
agent: AgentLocation::System,
|
||
}
|
||
}
|
||
|
||
/// Use the agent at `agent` instead of the user's own.
|
||
pub fn with_agent(mut self, agent: AgentLocation) -> Self {
|
||
self.agent = agent;
|
||
self
|
||
}
|
||
/// Must run on a worker: keychain and private-key reads are blocking.
|
||
pub async fn connect(
|
||
&self,
|
||
config: &SshConnectionConfig,
|
||
broker: Arc<SshPrompts>,
|
||
) -> Result<(client::Handle<SshClientHandler>, String)> {
|
||
self.connect_with(config, broker, &self.secrets).await
|
||
}
|
||
|
||
/// Same as [`Self::connect`], for a connection that carries port
|
||
/// forwards. The receiver yields every channel the server opens for a
|
||
/// remote forward, and ends when the connection does: the handler that
|
||
/// feeds it lives exactly as long as the connection, so this is how an
|
||
/// idle forward learns that its connection is gone.
|
||
pub async fn connect_forwarding(
|
||
&self,
|
||
config: &SshConnectionConfig,
|
||
broker: Arc<SshPrompts>,
|
||
) -> Result<(SshHandle, String, mpsc::UnboundedReceiver<ForwardedTcpip>)> {
|
||
let (sender, receiver) = mpsc::unbounded_channel();
|
||
let (handle, fingerprint) = self
|
||
.connect_inner(config, broker, &self.secrets, Some(sender))
|
||
.await?;
|
||
Ok((handle, fingerprint, receiver))
|
||
}
|
||
|
||
/// The keychain this connector reads saved secrets from.
|
||
pub(super) fn secrets(&self) -> &SharedSecretStore {
|
||
&self.secrets
|
||
}
|
||
|
||
/// Same as [`Self::connect`], with saved secrets read from `secrets`.
|
||
pub(super) async fn connect_with(
|
||
&self,
|
||
config: &SshConnectionConfig,
|
||
broker: Arc<SshPrompts>,
|
||
secrets: &SharedSecretStore,
|
||
) -> Result<(client::Handle<SshClientHandler>, String)> {
|
||
self.connect_inner(config, broker, secrets, None).await
|
||
}
|
||
|
||
async fn connect_inner(
|
||
&self,
|
||
config: &SshConnectionConfig,
|
||
broker: Arc<SshPrompts>,
|
||
secrets: &SharedSecretStore,
|
||
forwarded: Option<mpsc::UnboundedSender<ForwardedTcpip>>,
|
||
) -> Result<(client::Handle<SshClientHandler>, String)> {
|
||
let login = &config.login;
|
||
let tunnel = match &login.route {
|
||
LoginRoute::Direct => {
|
||
Box::new(step(&broker, tcp(&login.host, login.port)).await?) as Tunnel
|
||
}
|
||
LoginRoute::Proxy(proxy) => {
|
||
Box::new(step(&broker, through_proxy(proxy, login, secrets)).await?)
|
||
}
|
||
LoginRoute::Jump(hops) => self.through_jumps(hops, login, &broker, secrets).await?,
|
||
};
|
||
self.log_in(tunnel, login, None, &broker, secrets, forwarded)
|
||
.await
|
||
}
|
||
|
||
/// Open the SSH connection to `login`'s host over `tunnel` and log in.
|
||
/// `jump_host` names a jump host, for its questions.
|
||
async fn log_in(
|
||
&self,
|
||
tunnel: Tunnel,
|
||
login: &HostLogin,
|
||
jump_host: Option<&str>,
|
||
broker: &Arc<SshPrompts>,
|
||
secrets: &SharedSecretStore,
|
||
forwarded: Option<mpsc::UnboundedSender<ForwardedTcpip>>,
|
||
) -> Result<(SshHandle, String)> {
|
||
let fingerprint = Arc::new(Mutex::new(String::new()));
|
||
let handler = SshClientHandler {
|
||
host: login.host.clone(),
|
||
port: login.port,
|
||
jump_host: jump_host.map(str::to_string),
|
||
known_hosts_path: self.known_hosts_path.clone(),
|
||
known_hosts_lock: self.known_hosts_lock.clone(),
|
||
broker: broker.clone(),
|
||
fingerprint: fingerprint.clone(),
|
||
forwarded,
|
||
};
|
||
let connect = async {
|
||
client::connect_stream(ssh_config(), tunnel, handler)
|
||
.await
|
||
.map_err(|error| error.context(CONNECT_FAILED))
|
||
};
|
||
let mut handle = step(broker, connect).await?;
|
||
let prompts = Asker {
|
||
prompts: broker,
|
||
jump_host,
|
||
};
|
||
let mut shutdown = broker.shutdown_receiver();
|
||
tokio::select! {
|
||
result = authenticate(&mut handle, login, secrets, &self.agent, prompts) => result?,
|
||
_ = shutdown.changed() => bail!("连接已取消"),
|
||
}
|
||
let fingerprint = lock(&fingerprint).clone();
|
||
Ok((handle, fingerprint))
|
||
}
|
||
|
||
/// A stream to `login`'s host through `hops`, each logged in to over the
|
||
/// one before. A failure says which jump host it was at.
|
||
async fn through_jumps(
|
||
&self,
|
||
hops: &[JumpLogin],
|
||
login: &HostLogin,
|
||
broker: &Arc<SshPrompts>,
|
||
secrets: &SharedSecretStore,
|
||
) -> Result<Tunnel> {
|
||
// Nothing is connected while the way is known to be broken.
|
||
let hosts = hops
|
||
.iter()
|
||
.enumerate()
|
||
.map(|(ix, hop)| match hop {
|
||
JumpLogin::Host { name, login } => Ok((name.as_str(), &**login)),
|
||
JumpLogin::Deleted => Err(anyhow!(RouteFailure(format!(
|
||
"第 {} 台跳板主机已被删除,请编辑这台主机的连接方式",
|
||
ix + 1
|
||
)))),
|
||
})
|
||
.collect::<Result<Vec<_>>>()?;
|
||
let mut tunnel = None;
|
||
for (ix, (name, hop)) in hosts.iter().enumerate() {
|
||
let (next_host, next_port) = hosts
|
||
.get(ix + 1)
|
||
.map_or((login.host.as_str(), login.port), |(_, next)| {
|
||
(next.host.as_str(), next.port)
|
||
});
|
||
let reached = self
|
||
.jump(
|
||
tunnel.take(),
|
||
name,
|
||
hop,
|
||
(next_host, next_port),
|
||
broker,
|
||
secrets,
|
||
)
|
||
.await
|
||
.map_err(|error| {
|
||
if error.downcast_ref::<RouteFailure>().is_some() {
|
||
return error;
|
||
}
|
||
let reason = describe_login_error(&error);
|
||
error.context(RouteFailure(format!("跳板主机「{name}」:{reason}")))
|
||
})?;
|
||
tunnel = Some(reached);
|
||
}
|
||
tunnel.ok_or_else(|| anyhow!("没有可用的跳板主机"))
|
||
}
|
||
|
||
/// Log in to the jump host `hop`, over `tunnel` or else directly, and
|
||
/// open a channel through it to `next`.
|
||
///
|
||
/// The jump host's connection lives as long as that channel does: its
|
||
/// handle goes here, and the channel's stream keeps the connection's
|
||
/// task running until the connection over it ends.
|
||
async fn jump(
|
||
&self,
|
||
tunnel: Option<Tunnel>,
|
||
name: &str,
|
||
hop: &HostLogin,
|
||
next: (&str, u16),
|
||
broker: &Arc<SshPrompts>,
|
||
secrets: &SharedSecretStore,
|
||
) -> Result<Tunnel> {
|
||
let tunnel = match tunnel {
|
||
Some(tunnel) => tunnel,
|
||
None => Box::new(step(broker, tcp(&hop.host, hop.port)).await?),
|
||
};
|
||
let (handle, _) = self
|
||
.log_in(tunnel, hop, Some(name), broker, secrets, None)
|
||
.await?;
|
||
let (next_host, next_port) = next;
|
||
let open = async {
|
||
handle
|
||
.channel_open_direct_tcpip(next_host, u32::from(next_port), "127.0.0.1", 0)
|
||
.await
|
||
.map_err(|error| {
|
||
let reason = match &error {
|
||
russh::Error::ChannelOpenFailure(
|
||
russh::ChannelOpenFailure::ConnectFailed,
|
||
) => "连接失败",
|
||
russh::Error::ChannelOpenFailure(
|
||
russh::ChannelOpenFailure::AdministrativelyProhibited,
|
||
) => "服务器不允许端口转发",
|
||
_ => "无法打开转发通道",
|
||
};
|
||
let text =
|
||
format!("跳板主机「{name}」无法连接到 {next_host}:{next_port}:{reason}");
|
||
// Refused like a direct connection would be, so it is
|
||
// retried like one.
|
||
let refused = matches!(
|
||
error,
|
||
russh::Error::ChannelOpenFailure(russh::ChannelOpenFailure::ConnectFailed)
|
||
);
|
||
let error = if refused {
|
||
anyhow::Error::from(std::io::Error::new(
|
||
std::io::ErrorKind::ConnectionRefused,
|
||
text.clone(),
|
||
))
|
||
} else {
|
||
anyhow::Error::from(error)
|
||
};
|
||
error.context(RouteFailure(text))
|
||
})
|
||
};
|
||
let channel = step(broker, open).await?;
|
||
Ok(Box::new(channel.into_stream()))
|
||
}
|
||
}
|
||
|
||
/// What carries a connection to the host: a socket, or a channel through a
|
||
/// jump host.
|
||
type Tunnel = Box<dyn TunnelStream>;
|
||
|
||
trait TunnelStream: AsyncRead + AsyncWrite + Unpin + Send {}
|
||
|
||
impl<T: AsyncRead + AsyncWrite + Unpin + Send> TunnelStream for T {}
|
||
|
||
/// The key exchanges ShellRS offers, in OpenSSH's order: russh's own list
|
||
/// leaves out the NIST curves, which servers without curve25519 (Apache
|
||
/// SSHD, as many bastion hosts run) settle on with OpenSSH.
|
||
const KEX_ORDER: &[russh::kex::Name] = &[
|
||
russh::kex::MLKEM768X25519_SHA256,
|
||
russh::kex::CURVE25519,
|
||
russh::kex::CURVE25519_PRE_RFC_8731,
|
||
russh::kex::ECDH_SHA2_NISTP256,
|
||
russh::kex::ECDH_SHA2_NISTP384,
|
||
russh::kex::ECDH_SHA2_NISTP521,
|
||
russh::kex::DH_GEX_SHA256,
|
||
russh::kex::DH_G16_SHA512,
|
||
russh::kex::DH_G18_SHA512,
|
||
russh::kex::DH_G17_SHA512,
|
||
russh::kex::DH_G15_SHA512,
|
||
russh::kex::DH_G14_SHA256,
|
||
russh::kex::EXTENSION_SUPPORT_AS_CLIENT,
|
||
russh::kex::EXTENSION_SUPPORT_AS_SERVER,
|
||
russh::kex::EXTENSION_OPENSSH_STRICT_KEX_AS_CLIENT,
|
||
russh::kex::EXTENSION_OPENSSH_STRICT_KEX_AS_SERVER,
|
||
];
|
||
|
||
/// The smallest group a group exchange accepts, as OpenSSH's: russh asks
|
||
/// for 3072 bits, which Apache SSHD on an older Java cannot give (its
|
||
/// largest is 2048) and so ends the connection.
|
||
const MIN_DH_GROUP_BITS: usize = 2048;
|
||
|
||
/// Connects wherever OpenSSH with its default settings does.
|
||
fn ssh_config() -> Arc<client::Config> {
|
||
let defaults = client::GexParams::default();
|
||
Arc::new(client::Config {
|
||
keepalive_interval: Some(KEEPALIVE_INTERVAL),
|
||
keepalive_max: 3,
|
||
nodelay: true,
|
||
preferred: russh::Preferred {
|
||
kex: KEX_ORDER.into(),
|
||
..russh::Preferred::default()
|
||
},
|
||
gex: client::GexParams::new(
|
||
MIN_DH_GROUP_BITS,
|
||
defaults.preferred_group_size(),
|
||
defaults.max_group_size(),
|
||
)
|
||
.expect("2048 bits is the smallest group russh allows"),
|
||
..Default::default()
|
||
})
|
||
}
|
||
|
||
/// One step on the way to a host: given up after `CONNECT_TIMEOUT`, not
|
||
/// counting time a person spends answering, or when the connection is
|
||
/// cancelled.
|
||
async fn step<T>(broker: &SshPrompts, future: impl Future<Output = Result<T>>) -> Result<T> {
|
||
let mut shutdown = broker.shutdown_receiver();
|
||
tokio::select! {
|
||
result = timeout_excluding_prompts(future, broker.prompt_activity_receiver(), CONNECT_TIMEOUT) => result?,
|
||
_ = shutdown.changed() => bail!("连接已取消"),
|
||
}
|
||
}
|
||
|
||
/// A socket to `host:port`, failing the way russh's own connect does: a
|
||
/// name that does not resolve then counts as the network failing.
|
||
async fn tcp(host: &str, port: u16) -> Result<TcpStream> {
|
||
let socket = TcpStream::connect((host, port))
|
||
.await
|
||
.map_err(russh::Error::IO)
|
||
.context(CONNECT_FAILED)?;
|
||
// As russh's own connect does: the terminal's keystrokes go out at once.
|
||
let _ = socket.set_nodelay(true);
|
||
Ok(socket)
|
||
}
|
||
|
||
/// A stream to `login`'s host through `proxy`.
|
||
async fn through_proxy(
|
||
proxy: &ProxyLogin,
|
||
login: &HostLogin,
|
||
secrets: &SharedSecretStore,
|
||
) -> Result<TcpStream> {
|
||
let mut socket = TcpStream::connect((proxy.host.as_str(), proxy.port))
|
||
.await
|
||
.map_err(|error| {
|
||
let error = anyhow::Error::from(russh::Error::IO(error));
|
||
let reason = describe_login_error(&error);
|
||
error.context(RouteFailure(format!(
|
||
"无法连接代理服务器 {}:{}:{reason}",
|
||
proxy.host, proxy.port
|
||
)))
|
||
})?;
|
||
let _ = socket.set_nodelay(true);
|
||
let password = proxy
|
||
.password()
|
||
.and_then(|secret| saved_secret(secrets, &secret));
|
||
let auth = proxy.user.as_deref().map(|user| ProxyAuth {
|
||
user,
|
||
password: password.as_deref().map_or("", String::as_str),
|
||
});
|
||
handshake(&mut socket, proxy.kind, &login.host, login.port, auth)
|
||
.await
|
||
.map_err(|error| {
|
||
let text = error.to_string();
|
||
error.context(RouteFailure(text))
|
||
})?;
|
||
Ok(socket)
|
||
}
|
||
|
||
/// What went wrong on the way to a host, at a jump host or at the proxy, in
|
||
/// words that say where. The error underneath stays in the chain, so a
|
||
/// network failure on the way is retried like any other and a missing
|
||
/// password is still recognised.
|
||
#[derive(Debug)]
|
||
pub(super) struct RouteFailure(pub(super) String);
|
||
|
||
impl std::fmt::Display for RouteFailure {
|
||
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||
formatter.write_str(&self.0)
|
||
}
|
||
}
|
||
|
||
impl std::error::Error for RouteFailure {}
|
||
|
||
/// The questions of one host on the way. A jump host's say which one: the
|
||
/// host the user opened is a different one.
|
||
#[derive(Clone, Copy)]
|
||
struct Asker<'a> {
|
||
prompts: &'a SshPrompts,
|
||
jump_host: Option<&'a str>,
|
||
}
|
||
|
||
impl Asker<'_> {
|
||
async fn ask_credential(
|
||
&self,
|
||
need: MissingCredential,
|
||
kind: ConnectionPromptKind,
|
||
) -> Result<ConnectionPromptReply> {
|
||
self.prompts
|
||
.ask_credential(need, kind.at_jump_host(self.jump_host))
|
||
.await
|
||
}
|
||
}
|
||
impl SshConnectionConfig {
|
||
pub fn endpoint(&self) -> String {
|
||
self.login.endpoint()
|
||
}
|
||
}
|
||
|
||
pub struct SshPrompts {
|
||
pending: Mutex<HashMap<u64, oneshot::Sender<ConnectionPromptReply>>>,
|
||
events: Arc<dyn Fn(ConnectionPrompt) -> bool + Send + Sync>,
|
||
shutdown: watch::Receiver<bool>,
|
||
prompt_activity: watch::Sender<bool>,
|
||
/// Whether a password, passphrase or keyboard-interactive answer may be
|
||
/// asked of the user. When not, such a need fails the connection with a
|
||
/// [`MissingCredential`] instead.
|
||
interactive: bool,
|
||
}
|
||
|
||
/// A credential authentication needed and could not ask for, because the
|
||
/// connection runs without anyone to answer (a connection test, or a command
|
||
/// from the external CLI).
|
||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||
pub enum MissingCredential {
|
||
/// `rejected` when a password was tried and the server refused it.
|
||
Password {
|
||
rejected: bool,
|
||
},
|
||
/// `rejected` when a passphrase was tried and did not unlock the key.
|
||
Passphrase {
|
||
rejected: bool,
|
||
},
|
||
KeyboardInteractive,
|
||
}
|
||
|
||
impl std::fmt::Display for MissingCredential {
|
||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||
f.write_str(match self {
|
||
MissingCredential::Password { rejected: true } => "用户名或密码错误",
|
||
MissingCredential::Password { rejected: false } => "未填写密码",
|
||
MissingCredential::Passphrase { rejected: true } => "私钥口令错误",
|
||
MissingCredential::Passphrase { rejected: false } => "私钥已加密,未填写口令",
|
||
MissingCredential::KeyboardInteractive => "服务器要求键盘交互式认证,无法在测试中完成",
|
||
})
|
||
}
|
||
}
|
||
|
||
impl std::error::Error for MissingCredential {}
|
||
|
||
impl SshPrompts {
|
||
pub fn new(
|
||
events: Arc<dyn Fn(ConnectionPrompt) -> bool + Send + Sync>,
|
||
shutdown: watch::Receiver<bool>,
|
||
) -> Self {
|
||
let (prompt_activity, _) = watch::channel(false);
|
||
Self {
|
||
pending: Mutex::new(HashMap::new()),
|
||
events,
|
||
shutdown,
|
||
prompt_activity,
|
||
interactive: true,
|
||
}
|
||
}
|
||
|
||
/// Fail on a credential need instead of asking; see [`MissingCredential`].
|
||
pub fn non_interactive(mut self) -> Self {
|
||
self.interactive = false;
|
||
self
|
||
}
|
||
|
||
pub fn shutdown_receiver(&self) -> watch::Receiver<bool> {
|
||
self.shutdown.clone()
|
||
}
|
||
|
||
pub(super) fn prompt_activity_receiver(&self) -> watch::Receiver<bool> {
|
||
self.prompt_activity.subscribe()
|
||
}
|
||
|
||
async fn ask(&self, kind: ConnectionPromptKind) -> Result<ConnectionPromptReply> {
|
||
let request_id = NEXT_PROMPT_ID.fetch_add(1, Ordering::Relaxed);
|
||
let (sender, receiver) = oneshot::channel();
|
||
lock(&self.pending).insert(request_id, sender);
|
||
let _ = self.prompt_activity.send(true);
|
||
let result = async {
|
||
if !(self.events)(ConnectionPrompt::new(request_id, kind)) {
|
||
bail!("连接视图已关闭");
|
||
}
|
||
let mut shutdown = self.shutdown.clone();
|
||
tokio::select! {
|
||
reply = receiver => reply.map_err(|_| anyhow!("认证请求已取消")),
|
||
_ = shutdown.changed() => bail!("连接已取消"),
|
||
}
|
||
}
|
||
.await;
|
||
lock(&self.pending).remove(&request_id);
|
||
let _ = self.prompt_activity.send(false);
|
||
result
|
||
}
|
||
|
||
/// Ask for a credential, or fail with `need` when nobody may be asked.
|
||
async fn ask_credential(
|
||
&self,
|
||
need: MissingCredential,
|
||
kind: ConnectionPromptKind,
|
||
) -> Result<ConnectionPromptReply> {
|
||
if !self.interactive {
|
||
return Err(need.into());
|
||
}
|
||
self.ask(kind).await
|
||
}
|
||
|
||
fn emit(&self, kind: ConnectionPromptKind) {
|
||
let request_id = NEXT_PROMPT_ID.fetch_add(1, Ordering::Relaxed);
|
||
(self.events)(ConnectionPrompt::new(request_id, kind));
|
||
}
|
||
|
||
pub fn respond(&self, request_id: u64, reply: ConnectionPromptReply) {
|
||
if let Some(sender) = lock(&self.pending).remove(&request_id) {
|
||
let _ = sender.send(reply);
|
||
}
|
||
}
|
||
|
||
pub fn cancel_all(&self) {
|
||
lock(&self.pending).clear();
|
||
}
|
||
}
|
||
|
||
/// Apply the network handshake timeout without counting time spent waiting
|
||
/// for an explicit answer from the user to a host-trust prompt.
|
||
pub(super) async fn timeout_excluding_prompts<F>(
|
||
future: F,
|
||
mut prompt_activity: watch::Receiver<bool>,
|
||
timeout: Duration,
|
||
) -> Result<F::Output>
|
||
where
|
||
F: Future,
|
||
{
|
||
tokio::pin!(future);
|
||
let mut remaining = timeout;
|
||
loop {
|
||
if *prompt_activity.borrow() {
|
||
tokio::select! {
|
||
result = &mut future => return Ok(result),
|
||
changed = prompt_activity.changed() => {
|
||
if changed.is_err() {
|
||
bail!("连接已取消");
|
||
}
|
||
}
|
||
}
|
||
continue;
|
||
}
|
||
|
||
let started = tokio::time::Instant::now();
|
||
tokio::select! {
|
||
result = &mut future => return Ok(result),
|
||
_ = tokio::time::sleep(remaining) => return Err(russh::Error::ConnectionTimeout.into()),
|
||
changed = prompt_activity.changed() => {
|
||
if changed.is_err() {
|
||
bail!("连接已取消");
|
||
}
|
||
remaining = remaining.saturating_sub(started.elapsed());
|
||
if remaining.is_zero() && !*prompt_activity.borrow() {
|
||
return Err(russh::Error::ConnectionTimeout.into());
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
pub type SshHandle = client::Handle<SshClientHandler>;
|
||
|
||
/// Lock a mutex that a panicking thread may have poisoned; the data it guards
|
||
/// here stays usable either way.
|
||
pub(super) fn lock<T>(mutex: &Mutex<T>) -> MutexGuard<'_, T> {
|
||
mutex.lock().unwrap_or_else(|error| error.into_inner())
|
||
}
|
||
|
||
/// A connection the server opened back to this client for a remote forward
|
||
/// (`ssh -R`): someone connected to the port the server listens on for us.
|
||
#[non_exhaustive]
|
||
pub struct ForwardedTcpip {
|
||
pub channel: russh::Channel<client::Msg>,
|
||
/// Accepts or refuses the channel. Dropping it refuses.
|
||
pub reply: client::ChannelOpenHandle,
|
||
/// The address and port on the server that was connected to.
|
||
pub connected_address: String,
|
||
pub connected_port: u32,
|
||
pub originator_address: String,
|
||
pub originator_port: u32,
|
||
}
|
||
|
||
pub struct SshClientHandler {
|
||
host: String,
|
||
port: u16,
|
||
/// The jump host's name, when this connection is to one.
|
||
jump_host: Option<String>,
|
||
known_hosts_path: PathBuf,
|
||
known_hosts_lock: Arc<Mutex<()>>,
|
||
broker: Arc<SshPrompts>,
|
||
fingerprint: Arc<Mutex<String>>,
|
||
/// Where channels for a remote forward go. Only a forwarding connection
|
||
/// has one; every other connection refuses such channels.
|
||
forwarded: Option<mpsc::UnboundedSender<ForwardedTcpip>>,
|
||
}
|
||
|
||
impl client::Handler for SshClientHandler {
|
||
type Error = anyhow::Error;
|
||
|
||
async fn check_server_key(
|
||
&mut self,
|
||
server_public_key: &russh::keys::PublicKeyOrCertificate,
|
||
) -> Result<bool, Self::Error> {
|
||
let key = server_public_key.public_key();
|
||
let fingerprint = key.fingerprint(HashAlg::Sha256).to_string();
|
||
*lock(&self.fingerprint) = fingerprint.clone();
|
||
let algorithm = key.algorithm().to_string();
|
||
let known = {
|
||
let _guard = lock(&self.known_hosts_lock);
|
||
read_known_keys(&self.host, self.port, &self.known_hosts_path)?
|
||
};
|
||
if known.iter().any(|(_, saved)| saved == &key) {
|
||
return Ok(true);
|
||
}
|
||
if !known.is_empty() {
|
||
let old = known
|
||
.iter()
|
||
.map(|(_, saved)| saved.fingerprint(HashAlg::Sha256).to_string())
|
||
.collect();
|
||
self.broker.emit(
|
||
ConnectionPromptKind::host_key_changed(
|
||
&self.host,
|
||
self.port,
|
||
algorithm,
|
||
old,
|
||
fingerprint,
|
||
&self.known_hosts_path,
|
||
)
|
||
.at_jump_host(self.jump_host.as_deref()),
|
||
);
|
||
return Ok(false);
|
||
}
|
||
|
||
let reply = self
|
||
.broker
|
||
.ask(
|
||
ConnectionPromptKind::unknown_host(&self.host, self.port, algorithm, fingerprint)
|
||
.at_jump_host(self.jump_host.as_deref()),
|
||
)
|
||
.await?;
|
||
if !matches!(reply, ConnectionPromptReply::TrustAndSave) {
|
||
return Ok(false);
|
||
}
|
||
let _guard = lock(&self.known_hosts_lock);
|
||
let known = read_known_keys(&self.host, self.port, &self.known_hosts_path)?;
|
||
if known.iter().any(|(_, saved)| saved == &key) {
|
||
return Ok(true);
|
||
}
|
||
if !known.is_empty() {
|
||
bail!("保存主机密钥时发现信任文件已发生变化");
|
||
}
|
||
learn_known_hosts_path(&self.host, self.port, &key, &self.known_hosts_path)
|
||
.map_err(|_| anyhow!("无法写入主机信任文件:{}", self.known_hosts_path.display()))?;
|
||
Ok(true)
|
||
}
|
||
|
||
/// The server only opens these after a `tcpip-forward` request, which
|
||
/// nothing but a forwarding connection sends. One that arrives anyway is
|
||
/// refused rather than accepted and left dangling.
|
||
#[allow(clippy::too_many_arguments)]
|
||
async fn server_channel_open_forwarded_tcpip(
|
||
&mut self,
|
||
channel: russh::Channel<client::Msg>,
|
||
connected_address: &str,
|
||
connected_port: u32,
|
||
originator_address: &str,
|
||
originator_port: u32,
|
||
reply: client::ChannelOpenHandle,
|
||
_: &mut client::Session,
|
||
) -> Result<(), Self::Error> {
|
||
match &self.forwarded {
|
||
// The forward decides: it accepts once it has reached its target.
|
||
// Should it be gone already, the undelivered `reply` is dropped
|
||
// with the message, which refuses the channel.
|
||
Some(forwarded) => {
|
||
let _ = forwarded.send(ForwardedTcpip {
|
||
channel,
|
||
reply,
|
||
connected_address: connected_address.to_string(),
|
||
connected_port,
|
||
originator_address: originator_address.to_string(),
|
||
originator_port,
|
||
});
|
||
}
|
||
None => {
|
||
reply
|
||
.reject(russh::ChannelOpenFailure::AdministrativelyProhibited)
|
||
.await
|
||
}
|
||
}
|
||
Ok(())
|
||
}
|
||
}
|
||
|
||
/// Whether an error means the network or the connection failed, as opposed
|
||
/// to the server refusing a login or the user declining a host key. Only the
|
||
/// first kind is worth retrying without anyone looking.
|
||
pub fn is_network_error(error: &anyhow::Error) -> bool {
|
||
use std::io::ErrorKind;
|
||
error.chain().any(|cause| {
|
||
// A bare I/O error may also be a file that could not be read (the
|
||
// trust file, a private key), so only the socket's kinds count.
|
||
cause.downcast_ref::<std::io::Error>().is_some_and(|error| {
|
||
matches!(
|
||
error.kind(),
|
||
ErrorKind::NotConnected
|
||
| ErrorKind::ConnectionRefused
|
||
| ErrorKind::ConnectionReset
|
||
| ErrorKind::ConnectionAborted
|
||
| ErrorKind::TimedOut
|
||
| ErrorKind::UnexpectedEof
|
||
| ErrorKind::BrokenPipe
|
||
| ErrorKind::HostUnreachable
|
||
| ErrorKind::NetworkUnreachable
|
||
| ErrorKind::NetworkDown
|
||
)
|
||
}) || matches!(
|
||
cause.downcast_ref::<russh::Error>(),
|
||
Some(
|
||
// russh wraps the socket's error instead of chaining it, and
|
||
// a name that no longer resolves arrives the same way.
|
||
russh::Error::IO(_)
|
||
| russh::Error::Disconnect
|
||
| russh::Error::HUP
|
||
| russh::Error::ConnectionTimeout
|
||
| russh::Error::KeepaliveTimeout
|
||
| russh::Error::InactivityTimeout
|
||
| russh::Error::SendError
|
||
)
|
||
)
|
||
})
|
||
}
|
||
|
||
fn read_known_keys(host: &str, port: u16, path: &Path) -> Result<Vec<(usize, PublicKey)>> {
|
||
if path.exists() {
|
||
let contents = std::fs::read_to_string(path)
|
||
.with_context(|| format!("无法读取主机信任文件:{}", path.display()))?;
|
||
for line in contents.lines().map(str::trim) {
|
||
if line.is_empty() || line.starts_with('#') {
|
||
continue;
|
||
}
|
||
let mut fields = line.split_whitespace();
|
||
let valid = fields.next().is_some()
|
||
&& fields.next().is_some()
|
||
&& fields
|
||
.next()
|
||
.is_some_and(|encoded| parse_public_key_base64(encoded).is_ok());
|
||
if !valid {
|
||
bail!("主机信任文件已损坏:{}", path.display());
|
||
}
|
||
}
|
||
}
|
||
known_host_keys_path(host, port, path)
|
||
.map_err(|_| anyhow!("主机信任文件已损坏或无法读取:{}", path.display()))
|
||
}
|
||
|
||
async fn authenticate(
|
||
handle: &mut SshHandle,
|
||
login: &HostLogin,
|
||
secrets: &SharedSecretStore,
|
||
agent: &AgentLocation,
|
||
broker: Asker<'_>,
|
||
) -> Result<()> {
|
||
let user = login.user.as_str();
|
||
let method = login.method;
|
||
let first = handle
|
||
.authenticate_none(user)
|
||
.await
|
||
.map_err(|_| anyhow!("无法查询服务器支持的认证方式"))?;
|
||
if first.success() {
|
||
return Ok(());
|
||
}
|
||
let mut methods = remaining_methods(first);
|
||
let mut partial = false;
|
||
|
||
// A login without a password tries the agent quietly and moves on; a
|
||
// login that is only the agent says what went wrong with it.
|
||
if matches!(method, LoginMethod::NoPassword | LoginMethod::Agent) {
|
||
if methods.contains(&MethodKind::PublicKey) {
|
||
match try_agent(handle, user, agent).await? {
|
||
Ok(result) => {
|
||
if result.success() {
|
||
return Ok(());
|
||
}
|
||
partial = is_partial(&result);
|
||
methods = remaining_methods(result);
|
||
}
|
||
Err(problem) if method == LoginMethod::Agent => bail!(problem.to_string()),
|
||
Err(_) => {}
|
||
}
|
||
if method == LoginMethod::Agent && !partial {
|
||
bail!("服务器未接受 SSH Agent 中的密钥");
|
||
}
|
||
} else if method == LoginMethod::Agent {
|
||
bail!("服务器不接受公钥登录,无法使用 SSH Agent");
|
||
}
|
||
}
|
||
|
||
if matches!(method, LoginMethod::NoPassword | LoginMethod::Key)
|
||
&& methods.contains(&MethodKind::PublicKey)
|
||
{
|
||
let paths = key_paths(login)?;
|
||
for path in paths {
|
||
let Some(key) = load_private_key(&path, secrets, broker).await? else {
|
||
continue;
|
||
};
|
||
let hash = handle
|
||
.best_supported_rsa_hash()
|
||
.await
|
||
.map_err(|_| anyhow!("无法协商 RSA 签名算法"))?
|
||
.flatten();
|
||
let result = handle
|
||
.authenticate_publickey(user, PrivateKeyWithHashAlg::new(Arc::new(key), hash))
|
||
.await
|
||
.map_err(|_| anyhow!("私钥认证失败"))?;
|
||
if result.success() {
|
||
return Ok(());
|
||
}
|
||
partial = is_partial(&result);
|
||
methods = remaining_methods(result);
|
||
if partial {
|
||
break;
|
||
}
|
||
}
|
||
if method == LoginMethod::Key && !partial {
|
||
bail!("服务器未接受指定的私钥");
|
||
}
|
||
}
|
||
|
||
// Nothing typed is all a login without a password has. A server that
|
||
// wants more is told no here, not answered with a question; a code it
|
||
// asks for after a key is still asked, below.
|
||
if method == LoginMethod::NoPassword && !partial {
|
||
if methods.contains(&MethodKind::Password)
|
||
|| methods.contains(&MethodKind::KeyboardInteractive)
|
||
{
|
||
bail!("服务器要求密码,「无密码」不会询问;请改用「密码」或「使用凭据」");
|
||
}
|
||
bail!("服务器未接受 SSH Agent 和 ~/.ssh 中的任何私钥");
|
||
}
|
||
|
||
if method == LoginMethod::Password || partial {
|
||
if methods.contains(&MethodKind::Password) {
|
||
// Try what the host has saved before bothering anyone.
|
||
let mut saved_rejected = false;
|
||
if let Some(saved) = saved_secret(secrets, &login.password) {
|
||
let result = handle
|
||
.authenticate_password(user, saved.to_string())
|
||
.await
|
||
.map_err(|_| anyhow!("密码认证失败"))?;
|
||
if result.success() {
|
||
return Ok(());
|
||
}
|
||
// The entry stays: the user typed it into the host dialog,
|
||
// and deleting it behind their back would be baffling. This
|
||
// connection just falls back to asking, and says why.
|
||
saved_rejected = true;
|
||
partial = is_partial(&result);
|
||
methods = remaining_methods(result);
|
||
}
|
||
let instructions = if saved_rejected {
|
||
"已保存的密码被服务器拒绝,请重新输入"
|
||
} else {
|
||
"请输入登录密码"
|
||
};
|
||
if !partial && methods.contains(&MethodKind::Password) {
|
||
for _ in 0..AUTH_RETRIES {
|
||
let answer = ask_one_secret(
|
||
broker,
|
||
MissingCredential::Password {
|
||
rejected: saved_rejected,
|
||
},
|
||
"SSH 登录",
|
||
instructions,
|
||
"密码",
|
||
)
|
||
.await?;
|
||
let result = handle
|
||
.authenticate_password(user, answer.into_inner())
|
||
.await
|
||
.map_err(|_| anyhow!("密码认证失败"))?;
|
||
if result.success() {
|
||
return Ok(());
|
||
}
|
||
partial = is_partial(&result);
|
||
methods = remaining_methods(result);
|
||
if partial || !methods.contains(&MethodKind::Password) {
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
if methods.contains(&MethodKind::KeyboardInteractive) {
|
||
for _ in 0..AUTH_RETRIES {
|
||
if keyboard_interactive(handle, user, broker).await? {
|
||
return Ok(());
|
||
}
|
||
}
|
||
}
|
||
}
|
||
bail!("认证失败:服务器未接受可用的认证方式")
|
||
}
|
||
|
||
type Agent = russh::keys::agent::client::AgentClient<
|
||
Box<dyn russh::keys::agent::client::AgentStream + Send + Unpin>,
|
||
>;
|
||
|
||
/// Why the agent's keys could not be offered at all. `Display` is the text a
|
||
/// person reads. The cause is folded into the words rather than chained, so
|
||
/// a missing agent is never mistaken for a network failure worth retrying.
|
||
#[derive(Debug)]
|
||
enum AgentProblem {
|
||
Unreachable(String),
|
||
Empty,
|
||
/// Every key failed to sign: a locked agent, or one that asked to
|
||
/// confirm and was told no.
|
||
Unsigned,
|
||
}
|
||
|
||
impl std::fmt::Display for AgentProblem {
|
||
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||
match self {
|
||
AgentProblem::Unreachable(reason) => write!(formatter, "无法连接 SSH Agent:{reason}"),
|
||
AgentProblem::Empty => formatter.write_str("SSH Agent 中没有密钥,请先用 ssh-add 添加"),
|
||
AgentProblem::Unsigned => formatter.write_str("SSH Agent 拒绝了签名请求"),
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Offer the agent's keys one after another. The result is the server's
|
||
/// answer to the last key offered: a success, or the refusal that says what
|
||
/// the server will take next.
|
||
async fn try_agent(
|
||
handle: &mut SshHandle,
|
||
user: &str,
|
||
location: &AgentLocation,
|
||
) -> Result<Result<AuthResult, AgentProblem>> {
|
||
let mut agent = match connect_agent(location).await {
|
||
Ok(agent) => agent,
|
||
Err(problem) => return Ok(Err(problem)),
|
||
};
|
||
let identities = match agent.request_identities().await {
|
||
Ok(identities) if identities.is_empty() => return Ok(Err(AgentProblem::Empty)),
|
||
Ok(identities) => identities,
|
||
Err(error) => return Ok(Err(AgentProblem::Unreachable(error.to_string()))),
|
||
};
|
||
let hash = handle
|
||
.best_supported_rsa_hash()
|
||
.await
|
||
.map_err(|_| anyhow!("无法协商 SSH Agent 签名算法"))?
|
||
.flatten();
|
||
let mut last = None;
|
||
for identity in identities {
|
||
let key = identity.public_key().into_owned();
|
||
match handle
|
||
.authenticate_publickey_with(user, key, hash, &mut agent)
|
||
.await
|
||
{
|
||
Ok(result) if result.success() => return Ok(Ok(result)),
|
||
Ok(result) => last = Some(result),
|
||
Err(_) => continue,
|
||
}
|
||
}
|
||
Ok(last.ok_or(AgentProblem::Unsigned))
|
||
}
|
||
|
||
#[cfg(unix)]
|
||
async fn connect_agent(location: &AgentLocation) -> Result<Agent, AgentProblem> {
|
||
use russh::keys::agent::client::AgentClient;
|
||
|
||
let path = match location {
|
||
AgentLocation::At(path) => path.clone(),
|
||
AgentLocation::System => match std::env::var_os("SSH_AUTH_SOCK") {
|
||
Some(path) if !path.is_empty() => PathBuf::from(path),
|
||
_ => {
|
||
return Err(AgentProblem::Unreachable(
|
||
"没有设置 SSH_AUTH_SOCK".to_string(),
|
||
));
|
||
}
|
||
},
|
||
};
|
||
match tokio::time::timeout(AGENT_TIMEOUT, AgentClient::connect_uds(&path)).await {
|
||
Ok(Ok(agent)) => Ok(agent.dynamic()),
|
||
Ok(Err(russh::keys::Error::IO(error))) if error.kind() == std::io::ErrorKind::NotFound => {
|
||
Err(AgentProblem::Unreachable(format!(
|
||
"{} 不存在",
|
||
path.display()
|
||
)))
|
||
}
|
||
Ok(Err(error)) => Err(AgentProblem::Unreachable(error.to_string())),
|
||
Err(_) => Err(AgentProblem::Unreachable("没有响应".to_string())),
|
||
}
|
||
}
|
||
|
||
#[cfg(windows)]
|
||
async fn connect_agent(location: &AgentLocation) -> Result<Agent, AgentProblem> {
|
||
use russh::keys::agent::client::AgentClient;
|
||
|
||
/// Where the OpenSSH Authentication Agent service listens.
|
||
const SYSTEM_PIPE: &str = r"\\.\pipe\openssh-ssh-agent";
|
||
let pipe = match location {
|
||
AgentLocation::At(path) => path.as_os_str().to_owned(),
|
||
AgentLocation::System => SYSTEM_PIPE.into(),
|
||
};
|
||
match tokio::time::timeout(AGENT_TIMEOUT, AgentClient::connect_named_pipe(&pipe)).await {
|
||
Ok(Ok(agent)) => Ok(agent.dynamic()),
|
||
Ok(Err(russh::keys::Error::IO(error))) if error.kind() == std::io::ErrorKind::NotFound => {
|
||
Err(AgentProblem::Unreachable(
|
||
"OpenSSH Authentication Agent 服务没有运行".to_string(),
|
||
))
|
||
}
|
||
Ok(Err(error)) => Err(AgentProblem::Unreachable(error.to_string())),
|
||
Err(_) => Err(AgentProblem::Unreachable("没有响应".to_string())),
|
||
}
|
||
}
|
||
|
||
#[cfg(not(any(unix, windows)))]
|
||
async fn connect_agent(location: &AgentLocation) -> Result<Agent, AgentProblem> {
|
||
let _ = location;
|
||
Err(AgentProblem::Unreachable("这个平台不支持".to_string()))
|
||
}
|
||
|
||
fn key_paths(login: &HostLogin) -> Result<Vec<PathBuf>> {
|
||
if login.method == LoginMethod::Key {
|
||
return login
|
||
.key_path
|
||
.clone()
|
||
.map(|path| vec![path])
|
||
.ok_or_else(|| anyhow!("私钥认证需要选择私钥文件"));
|
||
}
|
||
let Some(home) = dirs::home_dir() else {
|
||
return Ok(Vec::new());
|
||
};
|
||
Ok(["id_ed25519", "id_ecdsa", "id_rsa"]
|
||
.into_iter()
|
||
.map(|name| home.join(".ssh").join(name))
|
||
.collect())
|
||
}
|
||
|
||
async fn load_private_key(
|
||
path: &Path,
|
||
secrets: &SharedSecretStore,
|
||
broker: Asker<'_>,
|
||
) -> Result<Option<russh::keys::PrivateKey>> {
|
||
if !path.exists() {
|
||
return Ok(None);
|
||
}
|
||
match load_secret_key(path, None) {
|
||
Ok(key) => return Ok(Some(key)),
|
||
Err(russh::keys::Error::KeyIsEncrypted) => {}
|
||
Err(_) => bail!("无法读取私钥文件:{}", path.display()),
|
||
}
|
||
// Passphrases are saved per key file, so one saved answer unlocks the same
|
||
// key for every host that uses it.
|
||
let mut saved_rejected = false;
|
||
if let Some(saved) = saved_secret(secrets, &SecretRef::passphrase(path)) {
|
||
if let Ok(key) = load_secret_key(path, Some(saved.as_str())) {
|
||
return Ok(Some(key));
|
||
}
|
||
saved_rejected = true;
|
||
}
|
||
let instructions = if saved_rejected {
|
||
format!("已保存的口令无法解开 {},请重新输入", path.display())
|
||
} else {
|
||
format!("请输入 {} 的口令", path.display())
|
||
};
|
||
for _ in 0..AUTH_RETRIES {
|
||
let answer = ask_one_secret(
|
||
broker,
|
||
MissingCredential::Passphrase {
|
||
rejected: saved_rejected,
|
||
},
|
||
"私钥口令",
|
||
&instructions,
|
||
"口令",
|
||
)
|
||
.await?;
|
||
if let Ok(key) = load_secret_key(path, Some(answer.expose())) {
|
||
return Ok(Some(key));
|
||
}
|
||
}
|
||
bail!("私钥口令错误次数过多")
|
||
}
|
||
|
||
/// Read a saved secret. A keychain that errors, is locked, or holds an empty
|
||
/// value counts as nothing saved: the connection then falls back to asking,
|
||
/// which is always better than refusing to connect.
|
||
///
|
||
/// This blocks. It only ever runs on the SSH worker thread, whose runtime
|
||
/// serves this one connection, and only while authentication is already
|
||
/// waiting on a person.
|
||
fn saved_secret(secrets: &SharedSecretStore, secret: &SecretRef) -> Option<Zeroizing<String>> {
|
||
secrets
|
||
.get(secret)
|
||
.ok()
|
||
.flatten()
|
||
.filter(|value| !value.is_empty())
|
||
}
|
||
|
||
async fn ask_one_secret(
|
||
broker: Asker<'_>,
|
||
need: MissingCredential,
|
||
title: &str,
|
||
instructions: &str,
|
||
label: &str,
|
||
) -> Result<crate::connection::ConnectionSecret> {
|
||
let reply = broker
|
||
.ask_credential(
|
||
need,
|
||
ConnectionPromptKind::authentication(
|
||
title,
|
||
instructions,
|
||
vec![ConnectionPromptField::new(label, false)],
|
||
),
|
||
)
|
||
.await?;
|
||
match reply {
|
||
ConnectionPromptReply::Answers(mut answers) if answers.len() == 1 => Ok(answers.remove(0)),
|
||
ConnectionPromptReply::Cancel => bail!("认证已取消"),
|
||
_ => bail!("认证回复无效"),
|
||
}
|
||
}
|
||
|
||
async fn keyboard_interactive(
|
||
handle: &mut SshHandle,
|
||
user: &str,
|
||
broker: Asker<'_>,
|
||
) -> Result<bool> {
|
||
let mut response = handle
|
||
.authenticate_keyboard_interactive_start(user, None)
|
||
.await
|
||
.map_err(|_| anyhow!("无法开始交互式认证"))?;
|
||
loop {
|
||
match response {
|
||
KeyboardInteractiveAuthResponse::Success => return Ok(true),
|
||
KeyboardInteractiveAuthResponse::Failure { .. } => return Ok(false),
|
||
KeyboardInteractiveAuthResponse::InfoRequest {
|
||
name,
|
||
instructions,
|
||
prompts,
|
||
} => {
|
||
let fields = prompts
|
||
.into_iter()
|
||
.map(|prompt| ConnectionPromptField::new(prompt.prompt, prompt.echo))
|
||
.collect();
|
||
let reply = broker
|
||
.ask_credential(
|
||
MissingCredential::KeyboardInteractive,
|
||
ConnectionPromptKind::authentication(name, instructions, fields),
|
||
)
|
||
.await?;
|
||
let ConnectionPromptReply::Answers(answers) = reply else {
|
||
bail!("交互式认证已取消")
|
||
};
|
||
response = handle
|
||
.authenticate_keyboard_interactive_respond(
|
||
answers
|
||
.into_iter()
|
||
.map(|answer| answer.into_inner())
|
||
.collect(),
|
||
)
|
||
.await
|
||
.map_err(|_| anyhow!("交互式认证失败"))?;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
fn remaining_methods(result: AuthResult) -> MethodSet {
|
||
match result {
|
||
AuthResult::Success => MethodSet::empty(),
|
||
AuthResult::Failure {
|
||
remaining_methods, ..
|
||
} => remaining_methods,
|
||
}
|
||
}
|
||
|
||
fn is_partial(result: &AuthResult) -> bool {
|
||
matches!(
|
||
result,
|
||
AuthResult::Failure {
|
||
partial_success: true,
|
||
..
|
||
}
|
||
)
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::read_known_keys;
|
||
#[test]
|
||
fn malformed_known_hosts_is_blocked() {
|
||
let directory = tempfile::tempdir().unwrap();
|
||
let path = directory.path().join("known_hosts");
|
||
std::fs::write(&path, "this is not a key\n").unwrap();
|
||
let error = read_known_keys("example.test", 22, &path).unwrap_err();
|
||
assert!(error.to_string().contains("已损坏"));
|
||
}
|
||
|
||
#[test]
|
||
fn only_a_failing_network_counts_as_a_network_error() {
|
||
use super::{MissingCredential, is_network_error};
|
||
use std::io::{Error, ErrorKind};
|
||
|
||
// What a refused or unresolvable connect looks like coming out of
|
||
// russh: the socket's error wrapped, under the connector's context.
|
||
let refused =
|
||
anyhow::Error::from(russh::Error::IO(Error::from(ErrorKind::ConnectionRefused)))
|
||
.context("无法建立 SSH 连接");
|
||
assert!(is_network_error(&refused));
|
||
let unresolved = anyhow::Error::from(russh::Error::IO(Error::other("no such host")));
|
||
assert!(is_network_error(&unresolved));
|
||
assert!(is_network_error(&russh::Error::KeepaliveTimeout.into()));
|
||
assert!(is_network_error(&russh::Error::Disconnect.into()));
|
||
assert!(is_network_error(
|
||
&Error::from(ErrorKind::ConnectionReset).into()
|
||
));
|
||
|
||
// A login the server refuses, a key the user does not trust and a
|
||
// file that cannot be read all need a person.
|
||
assert!(!is_network_error(
|
||
&MissingCredential::Password { rejected: true }.into()
|
||
));
|
||
assert!(!is_network_error(&russh::Error::UnknownKey.into()));
|
||
assert!(!is_network_error(
|
||
&Error::from(ErrorKind::PermissionDenied).into()
|
||
));
|
||
assert!(!is_network_error(&anyhow::anyhow!(
|
||
"认证失败:服务器未接受可用的认证方式"
|
||
)));
|
||
}
|
||
}
|