Files
shell-rs/src/ssh/tester.rs
T
XuzandClaude Opus 5.5 c0bc970de8 SSH:密钥交换按 OpenSSH 的默认,连接失败写明原因
用户的堡垒机是老 Java 上的 Apache SSHD 2.10:没有 curve25519,DH 组交换最大
2048 位。russh 默认不带 NIST 曲线、组交换要至少 3072 位,于是选了组交换、服务
器找不到素数就断开;OpenSSH 选 ecdh-sha2-nistp256 能连上。现在照 OpenSSH 的
顺序加上 ecdh-sha2-nistp*,组交换下限 2048(OpenSSH 的),不加 SHA-1、CBC。
route_tests 用只给 2048 位组、只有 NIST 曲线的服务端守着。

终端和 SFTP 的连接失败改用 describe_login_error:沿错误链给出真正的原因,
没有共同算法时写明服务器支持哪些,找不到时给整条错误链,不再只剩「无法建立
SSH 连接,请检查地址、端口和主机密钥」。

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 13:03:18 +08:00

365 lines
14 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Log in once with the host form's values, for its 「测试连接」 button.
use std::{
sync::{Arc, Mutex, OnceLock, Weak},
time::Duration,
};
use anyhow::Result;
use tokio::sync::watch;
use zeroize::Zeroizing;
use super::connection::{
MissingCredential, RouteFailure, SshConnectionConfig, SshConnector, SshPrompts, lock,
timeout_excluding_prompts,
};
use crate::{
connection::{
ConnectionPrompt, ConnectionPromptKind, ConnectionPromptReply, ConnectionTester, LoginTest,
TrustCallback,
},
host::{JumpLogin, LoginRoute},
secrets::{SecretRef, SecretStore, SharedSecretStore},
};
/// How long a test may take, not counting the time a person spends deciding
/// whether to trust the host key.
const TEST_TIMEOUT: Duration = Duration::from_secs(20);
/// Tests a login through the same connector as terminals and SFTP, so host
/// trust goes through the same file and lock.
pub struct SshConnectionTester {
connector: SshConnector,
}
impl SshConnectionTester {
pub fn new(connector: SshConnector) -> Self {
Self { connector }
}
}
impl ConnectionTester for SshConnectionTester {
fn test(&self, request: LoginTest, trust: TrustCallback) -> Result<(), String> {
let runtime = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.map_err(|error| format!("无法启动连接测试:{error}"))?;
runtime.block_on(self.run(request, trust))
}
}
/// What the host-key checks said along the way, which the connect error alone
/// does not tell apart.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
struct HostTrust {
declined: bool,
key_changed: bool,
}
impl SshConnectionTester {
async fn run(&self, request: LoginTest, trust: TrustCallback) -> Result<(), String> {
// Resolved apart from connecting, so a mistyped name says so instead
// of surfacing the resolver's own wording. Only the first leg is
// resolved here: a proxy or a jump host resolves what comes after.
let login = request.login();
let first = match &login.route {
LoginRoute::Direct => Some((login.host.as_str(), login.port)),
LoginRoute::Proxy(proxy) => Some((proxy.host.as_str(), proxy.port)),
LoginRoute::Jump(hops) => match hops.first() {
Some(JumpLogin::Host { login, .. }) => Some((login.host.as_str(), login.port)),
_ => None,
},
};
if let Some((host, port)) = first
&& tokio::net::lookup_host((host, port)).await.is_err()
{
return Err(format!("无法解析主机 {host}"));
}
let config = SshConnectionConfig::from(request.login());
let secrets: SharedSecretStore =
Arc::new(FormSecrets::new(&request, self.connector.secrets().clone()));
let host_trust = Arc::new(Mutex::new(HostTrust::default()));
let (_shutdown_tx, shutdown_rx) = watch::channel(false);
// The events callback answers the trust question through the broker,
// which does not exist yet when the callback is made.
let broker_slot: Arc<OnceLock<Weak<SshPrompts>>> = Arc::new(OnceLock::new());
let events = {
let trust = Arc::new(trust);
let host_trust = host_trust.clone();
let broker_slot = broker_slot.clone();
Arc::new(move |prompt: ConnectionPrompt| match prompt.kind() {
ConnectionPromptKind::UnknownHost(question) => {
let (trust, host_trust, broker_slot) =
(trust.clone(), host_trust.clone(), broker_slot.clone());
let (request_id, question) = (prompt.request_id(), question.clone());
// The answer takes a person; keep the connection's runtime
// free while they decide.
tokio::task::spawn_blocking(move || {
let trusted = trust(question);
if !trusted {
lock(&host_trust).declined = true;
}
if let Some(broker) = broker_slot.get().and_then(Weak::upgrade) {
broker.respond(
request_id,
if trusted {
ConnectionPromptReply::TrustAndSave
} else {
ConnectionPromptReply::Cancel
},
);
}
});
true
}
ConnectionPromptKind::HostKeyChanged(_) => {
lock(&host_trust).key_changed = true;
true
}
// Credential questions never get here: the broker is
// non-interactive and fails with a `MissingCredential`.
ConnectionPromptKind::Authentication(_) => false,
})
};
let broker = Arc::new(SshPrompts::new(events, shutdown_rx).non_interactive());
let _ = broker_slot.set(Arc::downgrade(&broker));
let connected = timeout_excluding_prompts(
self.connector
.connect_with(&config, broker.clone(), &secrets),
broker.prompt_activity_receiver(),
TEST_TIMEOUT,
)
.await
.and_then(|result| result);
match connected {
Ok((handle, _)) => {
let _ = handle
.disconnect(russh::Disconnect::ByApplication, "connection test", "zh-CN")
.await;
Ok(())
}
Err(error) => Err(describe_test_failure(&error, *lock(&host_trust))),
}
}
}
/// Why a test failed, in the words the notification shows.
fn describe_test_failure(error: &anyhow::Error, host_trust: HostTrust) -> String {
if host_trust.key_changed {
return "主机密钥与已保存的不一致,已阻止连接。请先核实服务器身份".into();
}
if host_trust.declined {
return "未信任该主机的密钥".into();
}
describe_login_error(error)
}
/// Why a login failed, from the error alone: where on the way to the host
/// it failed, a credential that was missing, the network's own answer, the
/// algorithms the server would not agree on, or whatever the error says,
/// down to its cause: 「无法建立 SSH 连接」 alone does not say what to fix.
pub fn describe_login_error(error: &anyhow::Error) -> String {
if let Some(failure) = error.downcast_ref::<RouteFailure>() {
return failure.to_string();
}
for cause in error.chain() {
if let Some(need) = cause.downcast_ref::<MissingCredential>() {
return need.to_string();
}
if let Some(io) = cause.downcast_ref::<std::io::Error>() {
return describe_connect_error(io);
}
match cause.downcast_ref::<russh::Error>() {
// russh wraps the socket's own error rather than chaining it.
Some(russh::Error::IO(io)) => return describe_connect_error(io),
Some(russh::Error::ConnectionTimeout) => return "连接超时".into(),
Some(russh::Error::NoCommonAlgo { kind, theirs, .. }) => {
return format!(
"无法建立 SSH 连接:和服务器没有共同的{}算法,服务器支持的是 {}",
algorithm_kind(kind),
theirs.join("、")
);
}
_ => {}
}
}
format!("{error:#}")
}
fn algorithm_kind(kind: &russh::AlgorithmKind) -> &'static str {
match kind {
russh::AlgorithmKind::Kex => "密钥交换",
russh::AlgorithmKind::Key => "主机密钥",
russh::AlgorithmKind::Cipher => "加密",
russh::AlgorithmKind::Mac => "消息校验(MAC)",
russh::AlgorithmKind::Compression => "压缩",
}
}
/// A connect error in plain words. The common kinds get an explanation;
/// anything else keeps the system's own description.
fn describe_connect_error(error: &std::io::Error) -> String {
use std::io::ErrorKind;
match error.kind() {
ErrorKind::ConnectionRefused => "连接被拒绝,该端口上没有服务在监听".into(),
ErrorKind::TimedOut => "连接超时".into(),
ErrorKind::HostUnreachable | ErrorKind::NetworkUnreachable => "主机不可达".into(),
_ => format!("无法连接:{error}"),
}
}
/// The secrets a test logs in with. For a login typed into the form, the
/// form is authoritative for the login password and for the passphrase of
/// the key it names, whether or not they were saved; anything else (a
/// default key's passphrase) is read from the keychain. A login through a
/// credential reads everything from the keychain. Nothing is ever written.
struct FormSecrets {
typed: Vec<(SecretRef, Option<Zeroizing<String>>)>,
fallback: SharedSecretStore,
}
impl FormSecrets {
fn new(request: &LoginTest, fallback: SharedSecretStore) -> Self {
let owned = |value: Option<&str>| value.map(|value| Zeroizing::new(value.to_string()));
let mut typed = Vec::new();
let login = request.login();
if request.is_typed() {
typed.push((login.password.clone(), owned(request.password())));
if let Some(path) = &login.key_path {
typed.push((SecretRef::passphrase(path), owned(request.passphrase())));
}
}
// The proxy is the form's whichever way the host logs in.
if let Some(proxy) = login.route.proxy_password() {
typed.push((proxy, owned(request.proxy_password())));
}
Self { typed, fallback }
}
}
impl SecretStore for FormSecrets {
fn get(&self, secret: &SecretRef) -> anyhow::Result<Option<Zeroizing<String>>> {
match self.typed.iter().find(|(key, _)| key == secret) {
Some((_, value)) => Ok(value.clone()),
None => self.fallback.get(secret),
}
}
fn set(&self, _: &SecretRef, _: &str) -> anyhow::Result<()> {
Ok(())
}
fn delete(&self, _: &SecretRef) -> anyhow::Result<()> {
Ok(())
}
fn is_available(&self) -> bool {
false
}
}
#[cfg(test)]
mod tests {
use std::io::{Error, ErrorKind};
use anyhow::anyhow;
use super::{HostTrust, MissingCredential, RouteFailure, describe_test_failure};
fn reason(error: anyhow::Error) -> String {
describe_test_failure(&error, HostTrust::default())
}
#[test]
fn host_trust_outranks_the_connect_error() {
let error = anyhow!("无法建立 SSH 连接,请检查地址、端口和主机密钥");
let changed = HostTrust {
key_changed: true,
..HostTrust::default()
};
assert!(describe_test_failure(&error, changed).starts_with("主机密钥与已保存的不一致"));
let declined = HostTrust {
declined: true,
..HostTrust::default()
};
assert_eq!(
describe_test_failure(&error, declined),
"未信任该主机的密钥"
);
}
#[test]
fn missing_credentials_name_what_was_wrong() {
assert_eq!(
reason(MissingCredential::Password { rejected: true }.into()),
"用户名或密码错误"
);
assert_eq!(
reason(MissingCredential::Password { rejected: false }.into()),
"未填写密码"
);
assert_eq!(
reason(MissingCredential::Passphrase { rejected: true }.into()),
"私钥口令错误"
);
assert_eq!(
reason(MissingCredential::Passphrase { rejected: false }.into()),
"私钥已加密,未填写口令"
);
assert!(reason(MissingCredential::KeyboardInteractive.into()).contains("键盘交互"));
}
#[test]
fn network_errors_are_explained_under_any_context() {
let refused = anyhow::Error::from(Error::from(ErrorKind::ConnectionRefused))
.context("无法建立 SSH 连接");
assert_eq!(reason(refused), "连接被拒绝,该端口上没有服务在监听");
assert_eq!(
reason(Error::from(ErrorKind::HostUnreachable).into()),
"主机不可达"
);
assert_eq!(reason(russh::Error::ConnectionTimeout.into()), "连接超时");
let wrapped =
anyhow::Error::from(russh::Error::IO(Error::from(ErrorKind::ConnectionRefused)))
.context("无法建立 SSH 连接");
assert_eq!(reason(wrapped), "连接被拒绝,该端口上没有服务在监听");
}
#[test]
fn a_failure_on_the_way_says_where_it_happened() {
let missing = anyhow::Error::from(MissingCredential::Password { rejected: false })
.context(RouteFailure("跳板主机「阿里云99」:未填写密码".into()));
assert_eq!(reason(missing), "跳板主机「阿里云99」:未填写密码");
}
#[test]
fn anything_else_keeps_the_connector_wording() {
assert_eq!(
reason(anyhow!("服务器未接受指定的私钥")),
"服务器未接受指定的私钥"
);
// With what lies under it, which says what to fix.
let kex = anyhow::Error::from(russh::Error::Kex)
.context("无法建立 SSH 连接,请检查地址、端口和主机密钥");
assert_eq!(
reason(kex),
"无法建立 SSH 连接,请检查地址、端口和主机密钥: Key exchange failed"
);
}
#[test]
fn a_server_of_other_algorithms_says_which_it_has() {
let mac = anyhow::Error::from(russh::Error::NoCommonAlgo {
kind: russh::AlgorithmKind::Mac,
ours: vec!["hmac-sha2-256".into()],
theirs: vec!["hmac-sha1".into(), "hmac-md5".into()],
})
.context("无法建立 SSH 连接,请检查地址、端口和主机密钥");
assert_eq!(
reason(mac),
"无法建立 SSH 连接:和服务器没有共同的消息校验(MAC)算法,服务器支持的是 hmac-sha1、hmac-md5"
);
}
}