测试连接改为真实登录,结果以通知呈现
This commit is contained in:
1 parent
6b1cd97317
commit
48ac2b95aa
9 files changed
+1184
-154
No files matched your search
+123
-15
@@ -78,6 +78,21 @@ impl SshConnector {
|
||||
&self,
|
||||
config: &SshConnectionConfig,
|
||||
broker: Arc<SshPrompts>,
|
||||
) -> Result<(client::Handle<SshClientHandler>, String)> {
|
||||
self.connect_with(config, broker, &self.secrets).await
|
||||
}
|
||||
|
||||
/// 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)> {
|
||||
let fingerprint = Arc::new(Mutex::new(String::new()));
|
||||
let handler = SshClientHandler {
|
||||
@@ -104,7 +119,7 @@ impl SshConnector {
|
||||
_ = shutdown.changed() => bail!("连接已取消"),
|
||||
};
|
||||
tokio::select! {
|
||||
result = authenticate(&mut handle, config, &self.secrets, &broker) => result?,
|
||||
result = authenticate(&mut handle, config, secrets, &broker) => result?,
|
||||
_ = shutdown.changed() => bail!("连接已取消"),
|
||||
}
|
||||
let fingerprint = fingerprint
|
||||
@@ -115,6 +130,26 @@ impl SshConnector {
|
||||
}
|
||||
}
|
||||
impl SshConnectionConfig {
|
||||
pub fn new(
|
||||
host: impl Into<String>,
|
||||
port: u16,
|
||||
user: impl Into<String>,
|
||||
auth: AuthKind,
|
||||
key_path: Option<PathBuf>,
|
||||
) -> Self {
|
||||
Self {
|
||||
host: host.into(),
|
||||
port,
|
||||
user: user.into(),
|
||||
auth,
|
||||
key_path,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn host(&self) -> &str {
|
||||
&self.host
|
||||
}
|
||||
|
||||
pub fn endpoint(&self) -> String {
|
||||
format!("{}@{}:{}", self.user, self.host, self.port)
|
||||
}
|
||||
@@ -125,8 +160,41 @@ pub struct SshPrompts {
|
||||
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).
|
||||
#[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>,
|
||||
@@ -138,14 +206,21 @@ impl SshPrompts {
|
||||
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()
|
||||
}
|
||||
|
||||
fn prompt_activity_receiver(&self) -> watch::Receiver<bool> {
|
||||
pub(super) fn prompt_activity_receiver(&self) -> watch::Receiver<bool> {
|
||||
self.prompt_activity.subscribe()
|
||||
}
|
||||
|
||||
@@ -176,6 +251,18 @@ impl SshPrompts {
|
||||
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
|
||||
}
|
||||
|
||||
async fn emit(&self, kind: ConnectionPromptKind) {
|
||||
let request_id = NEXT_PROMPT_ID.fetch_add(1, Ordering::Relaxed);
|
||||
(self.events)(ConnectionPrompt::new(request_id, kind));
|
||||
@@ -202,7 +289,7 @@ impl SshPrompts {
|
||||
|
||||
/// Apply the network handshake timeout without counting time spent waiting
|
||||
/// for an explicit answer from the user to a host-trust prompt.
|
||||
async fn timeout_excluding_prompts<F>(
|
||||
pub(super) async fn timeout_excluding_prompts<F>(
|
||||
future: F,
|
||||
mut prompt_activity: watch::Receiver<bool>,
|
||||
timeout: Duration,
|
||||
@@ -434,7 +521,16 @@ where
|
||||
};
|
||||
if !partial && methods.contains(&MethodKind::Password) {
|
||||
for _ in 0..AUTH_RETRIES {
|
||||
let answer = ask_one_secret(broker, "SSH 登录", instructions, "密码").await?;
|
||||
let answer = ask_one_secret(
|
||||
broker,
|
||||
MissingCredential::Password {
|
||||
rejected: saved_rejected,
|
||||
},
|
||||
"SSH 登录",
|
||||
instructions,
|
||||
"密码",
|
||||
)
|
||||
.await?;
|
||||
let result = handle
|
||||
.authenticate_password(&config.user, answer.into_inner())
|
||||
.await
|
||||
@@ -547,7 +643,16 @@ async fn load_private_key(
|
||||
format!("请输入 {} 的口令", path.display())
|
||||
};
|
||||
for _ in 0..AUTH_RETRIES {
|
||||
let answer = ask_one_secret(broker, "私钥口令", &instructions, "口令").await?;
|
||||
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));
|
||||
}
|
||||
@@ -577,16 +682,20 @@ fn saved_secret(secrets: &SharedSecretStore, secret: &SecretRef) -> Option<Zeroi
|
||||
|
||||
async fn ask_one_secret(
|
||||
broker: &SshPrompts,
|
||||
need: MissingCredential,
|
||||
title: &str,
|
||||
instructions: &str,
|
||||
label: &str,
|
||||
) -> Result<crate::connection::ConnectionSecret> {
|
||||
let reply = broker
|
||||
.ask(ConnectionPromptKind::authentication(
|
||||
title,
|
||||
instructions,
|
||||
vec![ConnectionPromptField::new(label, false)],
|
||||
))
|
||||
.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)),
|
||||
@@ -621,11 +730,10 @@ where
|
||||
.map(|prompt| ConnectionPromptField::new(prompt.prompt, prompt.echo))
|
||||
.collect();
|
||||
let reply = broker
|
||||
.ask(ConnectionPromptKind::authentication(
|
||||
name,
|
||||
instructions,
|
||||
fields,
|
||||
))
|
||||
.ask_credential(
|
||||
MissingCredential::KeyboardInteractive,
|
||||
ConnectionPromptKind::authentication(name, instructions, fields),
|
||||
)
|
||||
.await?;
|
||||
let ConnectionPromptReply::Answers(answers) = reply else {
|
||||
bail!("交互式认证已取消")
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
//! Shared SSH connection service and remote terminal adapter.
|
||||
mod connection;
|
||||
mod probe;
|
||||
mod tester;
|
||||
mod transport;
|
||||
pub use connection::{SshConnectionConfig, SshConnector, SshHandle, SshPrompts};
|
||||
pub use tester::SshConnectionTester;
|
||||
pub use transport::SshTerminalTransportProvider;
|
||||
@@ -0,0 +1,309 @@
|
||||
//! Log in once with the session 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, SshConnectionConfig, SshConnector, SshPrompts, timeout_excluding_prompts,
|
||||
};
|
||||
use crate::{
|
||||
connection::{
|
||||
ConnectionPrompt, ConnectionPromptKind, ConnectionPromptReply, ConnectionTester, LoginTest,
|
||||
TrustCallback,
|
||||
},
|
||||
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.
|
||||
if tokio::net::lookup_host((request.host(), request.port()))
|
||||
.await
|
||||
.is_err()
|
||||
{
|
||||
return Err(format!("无法解析主机 {}", request.host()));
|
||||
}
|
||||
|
||||
let config = SshConnectionConfig::new(
|
||||
request.host(),
|
||||
request.port(),
|
||||
request.user(),
|
||||
request.auth(),
|
||||
request.key_path().map(Into::into),
|
||||
);
|
||||
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))),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn lock<T>(mutex: &Mutex<T>) -> std::sync::MutexGuard<'_, T> {
|
||||
mutex.lock().unwrap_or_else(|error| error.into_inner())
|
||||
}
|
||||
|
||||
/// 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();
|
||||
}
|
||||
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(),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
error.to_string()
|
||||
}
|
||||
|
||||
/// 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. 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. Nothing is ever written.
|
||||
struct FormSecrets {
|
||||
password: (SecretRef, Option<Zeroizing<String>>),
|
||||
passphrase: Option<(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()));
|
||||
Self {
|
||||
password: (
|
||||
SecretRef::password(request.user(), request.host(), request.port()),
|
||||
owned(request.password()),
|
||||
),
|
||||
passphrase: request
|
||||
.key_path()
|
||||
.map(|path| (SecretRef::passphrase(path), owned(request.passphrase()))),
|
||||
fallback,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl SecretStore for FormSecrets {
|
||||
fn get(&self, secret: &SecretRef) -> anyhow::Result<Option<Zeroizing<String>>> {
|
||||
if *secret == self.password.0 {
|
||||
return Ok(self.password.1.clone());
|
||||
}
|
||||
if let Some((key, value)) = &self.passphrase
|
||||
&& secret == key
|
||||
{
|
||||
return Ok(value.clone());
|
||||
}
|
||||
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, 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 anything_else_keeps_the_connector_wording() {
|
||||
assert_eq!(
|
||||
reason(anyhow!("服务器未接受指定的私钥")),
|
||||
"服务器未接受指定的私钥"
|
||||
);
|
||||
}
|
||||
}
|
||||
+182
-1
@@ -323,7 +323,7 @@ async fn open_probe<H: client::Handler>(
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::secrets::{InMemorySecretStore, NoSecretStore, SecretStore as _};
|
||||
use crate::secrets::{InMemorySecretStore, NoSecretStore, SecretRef, SecretStore as _};
|
||||
use crate::session::HostOs;
|
||||
use crate::ssh::probe::{PROBE_COMMAND, WINDOWS_PROBE_COMMAND};
|
||||
use crate::terminal::TerminalSecret;
|
||||
@@ -910,6 +910,187 @@ mod tests {
|
||||
)
|
||||
}
|
||||
|
||||
/// Run a connection test the way the session form does, recording
|
||||
/// whether (and about what) it asked to trust the host.
|
||||
fn test_login(
|
||||
request: crate::connection::LoginTest,
|
||||
known_hosts: &Path,
|
||||
keychain: Arc<InMemorySecretStore>,
|
||||
trust: bool,
|
||||
) -> (Result<(), String>, Vec<String>) {
|
||||
use crate::connection::ConnectionTester as _;
|
||||
let asked = Arc::new(Mutex::new(Vec::new()));
|
||||
let tester = crate::ssh::SshConnectionTester::new(SshConnector::new(known_hosts, keychain));
|
||||
let result = tester.test(request, {
|
||||
let asked = asked.clone();
|
||||
Box::new(move |prompt| {
|
||||
asked.lock().unwrap().push(prompt.fingerprint().to_string());
|
||||
trust
|
||||
})
|
||||
});
|
||||
let asked = asked.lock().unwrap().clone();
|
||||
(result, asked)
|
||||
}
|
||||
|
||||
fn login_request(port: u16) -> crate::connection::LoginTest {
|
||||
crate::connection::LoginTest::new("127.0.0.1", port, "tester", AuthKind::Password)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_connection_test_logs_in_with_the_form_password_and_trusts_on_request() {
|
||||
let Some(server) = start_server(TestAuth::Password) else {
|
||||
eprintln!("loopback sockets are unavailable in this sandbox; skipping");
|
||||
return;
|
||||
};
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
let known_hosts = directory.path().join("known_hosts");
|
||||
let keychain = Arc::new(InMemorySecretStore::default());
|
||||
|
||||
let (result, asked) = test_login(
|
||||
login_request(server.port).with_password(TEST_PASSWORD),
|
||||
&known_hosts,
|
||||
keychain.clone(),
|
||||
true,
|
||||
);
|
||||
assert_eq!(result, Ok(()));
|
||||
assert_eq!(asked.len(), 1, "陌生主机应当问一次是否信任");
|
||||
assert!(
|
||||
std::fs::read_to_string(&known_hosts)
|
||||
.unwrap()
|
||||
.contains("127.0.0.1"),
|
||||
"信任之后应当写进信任文件"
|
||||
);
|
||||
assert!(keychain.is_empty(), "测试连接不该写钥匙串");
|
||||
|
||||
// Trusted now: the second test asks nothing.
|
||||
let (result, asked) = test_login(
|
||||
login_request(server.port).with_password(TEST_PASSWORD),
|
||||
&known_hosts,
|
||||
keychain,
|
||||
false,
|
||||
);
|
||||
assert_eq!(result, Ok(()));
|
||||
assert!(asked.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_wrong_form_password_fails_even_with_the_right_one_saved() {
|
||||
let Some(server) = start_server(TestAuth::Password) else {
|
||||
eprintln!("loopback sockets are unavailable in this sandbox; skipping");
|
||||
return;
|
||||
};
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
let known_hosts = directory.path().join("known_hosts");
|
||||
// What the user reported: the keychain holds the working password,
|
||||
// the form has been edited to a wrong one.
|
||||
let keychain = Arc::new(InMemorySecretStore::default());
|
||||
let saved = SecretRef::password("tester", "127.0.0.1", server.port);
|
||||
keychain.set(&saved, TEST_PASSWORD).unwrap();
|
||||
|
||||
let (result, _) = test_login(
|
||||
login_request(server.port).with_password("wrong-password"),
|
||||
&known_hosts,
|
||||
keychain.clone(),
|
||||
true,
|
||||
);
|
||||
assert_eq!(result, Err("用户名或密码错误".to_string()));
|
||||
|
||||
let (result, _) = test_login(
|
||||
login_request(server.port),
|
||||
&known_hosts,
|
||||
keychain.clone(),
|
||||
true,
|
||||
);
|
||||
assert_eq!(result, Err("未填写密码".to_string()));
|
||||
assert_eq!(
|
||||
keychain.get(&saved).unwrap().as_deref().map(String::as_str),
|
||||
Some(TEST_PASSWORD),
|
||||
"保存的密码原样不动"
|
||||
);
|
||||
assert_eq!(keychain.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn declining_to_trust_fails_the_test_and_saves_nothing() {
|
||||
let Some(server) = start_server(TestAuth::Password) else {
|
||||
eprintln!("loopback sockets are unavailable in this sandbox; skipping");
|
||||
return;
|
||||
};
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
let known_hosts = directory.path().join("known_hosts");
|
||||
|
||||
let (result, asked) = test_login(
|
||||
login_request(server.port).with_password(TEST_PASSWORD),
|
||||
&known_hosts,
|
||||
Arc::new(InMemorySecretStore::default()),
|
||||
false,
|
||||
);
|
||||
assert_eq!(result, Err("未信任该主机的密钥".to_string()));
|
||||
assert_eq!(asked.len(), 1);
|
||||
assert!(
|
||||
!known_hosts.exists()
|
||||
|| !std::fs::read_to_string(&known_hosts)
|
||||
.unwrap()
|
||||
.contains("127.0.0.1")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_changed_host_key_fails_the_test_without_asking() {
|
||||
let Some(server) = start_server(TestAuth::Password) else {
|
||||
eprintln!("loopback sockets are unavailable in this sandbox; skipping");
|
||||
return;
|
||||
};
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
let known_hosts = directory.path().join("known_hosts");
|
||||
let mut rng = russh::keys::key::safe_rng();
|
||||
let stale_key =
|
||||
russh::keys::PrivateKey::random(&mut rng, russh::keys::Algorithm::Ed25519).unwrap();
|
||||
learn_known_hosts_path(
|
||||
"127.0.0.1",
|
||||
server.port,
|
||||
stale_key.public_key(),
|
||||
&known_hosts,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let (result, asked) = test_login(
|
||||
login_request(server.port).with_password(TEST_PASSWORD),
|
||||
&known_hosts,
|
||||
Arc::new(InMemorySecretStore::default()),
|
||||
true,
|
||||
);
|
||||
assert!(
|
||||
result
|
||||
.as_ref()
|
||||
.is_err_and(|reason| reason.starts_with("主机密钥与已保存的不一致")),
|
||||
"{result:?}"
|
||||
);
|
||||
assert!(asked.is_empty(), "密钥变了不该再问要不要信任");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_closed_port_fails_the_test_with_the_reason() {
|
||||
let Ok(listener) = std::net::TcpListener::bind(("127.0.0.1", 0)) else {
|
||||
eprintln!("loopback sockets are unavailable in this sandbox; skipping");
|
||||
return;
|
||||
};
|
||||
let port = listener.local_addr().unwrap().port();
|
||||
drop(listener);
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
let (result, asked) = test_login(
|
||||
login_request(port).with_password(TEST_PASSWORD),
|
||||
&directory.path().join("known_hosts"),
|
||||
Arc::new(InMemorySecretStore::default()),
|
||||
true,
|
||||
);
|
||||
assert_eq!(
|
||||
result,
|
||||
Err("连接被拒绝,该端口上没有服务在监听".to_string())
|
||||
);
|
||||
assert!(asked.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_saved_password_connects_without_asking() {
|
||||
let Some(server) = start_server(TestAuth::Password) else {
|
||||
|
||||
Reference in new issue
Block a user