测试连接改为真实登录,结果以通知呈现

This commit is contained in:
Xuz committed 2026-09-24 22:38:22 +08:00
1 parent 6b1cd97317
commit 48ac2b95aa
9 files changed
+1184 -154

No files matched your search

+107
View File
@@ -73,6 +73,14 @@ impl UnknownHostPrompt {
pub fn fingerprint(&self) -> &str {
&self.fingerprint
}
/// What the trust dialog tells the user about the key.
pub fn description(&self) -> String {
format!(
"主机:{}:{}\n算法:{}\nSHA-256 指纹:{}\n\n请先确认该指纹来自可信渠道。",
self.host, self.port, self.algorithm, self.fingerprint,
)
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
@@ -212,3 +220,102 @@ impl std::fmt::Debug for ConnectionPromptReply {
}
}
}
/// What a connection test logs in with: the session form's current values,
/// saved or not. Secrets stay zeroized and never print.
pub struct LoginTest {
host: String,
port: u16,
user: String,
auth: crate::session::AuthKind,
key_path: Option<std::path::PathBuf>,
password: Option<Zeroizing<String>>,
passphrase: Option<Zeroizing<String>>,
}
impl LoginTest {
pub fn new(
host: impl Into<String>,
port: u16,
user: impl Into<String>,
auth: crate::session::AuthKind,
) -> Self {
Self {
host: host.into(),
port,
user: user.into(),
auth,
key_path: None,
password: None,
passphrase: None,
}
}
pub fn with_key_path(mut self, path: impl Into<std::path::PathBuf>) -> Self {
self.key_path = Some(path.into());
self
}
pub fn with_password(mut self, password: impl Into<String>) -> Self {
self.password = Some(Zeroizing::new(password.into()));
self
}
pub fn with_passphrase(mut self, passphrase: impl Into<String>) -> Self {
self.passphrase = Some(Zeroizing::new(passphrase.into()));
self
}
pub fn host(&self) -> &str {
&self.host
}
pub fn port(&self) -> u16 {
self.port
}
pub fn user(&self) -> &str {
&self.user
}
pub fn auth(&self) -> crate::session::AuthKind {
self.auth
}
pub fn key_path(&self) -> Option<&std::path::Path> {
self.key_path.as_deref()
}
pub fn password(&self) -> Option<&str> {
self.password.as_deref().map(String::as_str)
}
pub fn passphrase(&self) -> Option<&str> {
self.passphrase.as_deref().map(String::as_str)
}
}
impl std::fmt::Debug for LoginTest {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("LoginTest")
.field("host", &self.host)
.field("port", &self.port)
.field("user", &self.user)
.field("auth", &self.auth)
.field("key_path", &self.key_path)
.field("password", &self.password.as_ref().map(|_| "<redacted>"))
.field(
"passphrase",
&self.passphrase.as_ref().map(|_| "<redacted>"),
)
.finish()
}
}
/// Answers whether to trust a host key seen for the first time. It blocks
/// until a person decides; `false` also covers "nobody is there to ask".
pub type TrustCallback = Box<dyn Fn(UnknownHostPrompt) -> bool + Send + Sync>;
/// Logs in once with a [`LoginTest`] and hangs up, so the session form can
/// check its values before saving them.
pub trait ConnectionTester: Send + Sync + 'static {
/// Blocks: call it on a thread of its own. `Err` holds the reason, worded
/// for the user.
fn test(&self, request: LoginTest, trust: TrustCallback) -> Result<(), String>;
}
pub type SharedConnectionTester = std::sync::Arc<dyn ConnectionTester>;
+179 -97
View File
@@ -7,14 +7,15 @@ use gpui_kit::component::{
form::{Field, Form},
h_flex,
input::{Input, InputContentType, InputEvent, InputState},
notification::Notification,
select::{Select, SelectState},
v_flex,
};
use gpui_kit::prelude::FluentBuilder as _;
use gpui_kit::*;
use tokio::io::{AsyncReadExt as _, AsyncWriteExt as _};
use zeroize::Zeroizing;
use crate::connection::{LoginTest, SharedConnectionTester, TrustCallback, UnknownHostPrompt};
use crate::secrets::{SecretRef, SharedSecretStore};
use super::{AuthKind, GroupId, Session, SessionDraft, SessionId, SessionStore, group_options};
@@ -53,7 +54,8 @@ pub struct SessionForm {
group_ids: Vec<Option<GroupId>>,
error: Option<SharedString>,
testing_connection: bool,
connection_test_result: Option<(bool, SharedString)>,
/// Logs in with the form's current values for 「测试连接」.
tester: SharedConnectionTester,
editing_connected: bool,
/// Where saved secrets are read from. Writes go through the store, which
/// owns the one error channel.
@@ -72,6 +74,7 @@ impl SessionForm {
editing: Option<SessionId>,
preselect_group: Option<GroupId>,
store: Entity<SessionStore>,
tester: SharedConnectionTester,
window: &mut Window,
cx: &mut Context<Self>,
) -> Self {
@@ -183,7 +186,7 @@ impl SessionForm {
group_ids,
error: None,
testing_connection: false,
connection_test_result: None,
tester,
editing_connected,
secrets,
password_loaded: false,
@@ -283,47 +286,118 @@ impl SessionForm {
.detach();
}
/// Probe the endpoint currently shown in the form without saving it.
fn test_connection(&mut self, _: &ClickEvent, _: &mut Window, cx: &mut Context<Self>) {
/// The login the form's current values describe, saved or not, or why
/// there is nothing to test yet.
fn login_test(&self, cx: &App) -> Result<LoginTest, &'static str> {
let host = self.host.read(cx).value().trim().to_string();
let port = parse_port(self.port.read(cx).value().trim());
let user = self.user.read(cx).value().trim().to_string();
let auth = self
.auth
.read(cx)
.selected_index(cx)
.and_then(|ix| AuthKind::ALL.get(ix.row).copied())
.unwrap_or_default();
let key_path = self.key_path.read(cx).value().trim().to_string();
let port = match (host.is_empty(), port, user.is_empty()) {
(true, _, _) => return Err("请输入主机"),
(_, None, _) => return Err("端口必须是 1 到 65535 之间的数字"),
(_, _, true) => return Err("请输入用户名"),
(_, Some(port), _) => port,
};
if auth == AuthKind::Key && key_path.is_empty() {
return Err("私钥认证需要选择私钥文件");
}
let mut request = LoginTest::new(host, port, user, auth);
// Only what the chosen method uses, which is also what the form shows.
if uses_password(auth) {
let password = self.password.read(cx).value();
if !password.is_empty() {
request = request.with_password(password.to_string());
}
}
if auth == AuthKind::Key {
request = request.with_key_path(key_path);
let passphrase = self.passphrase.read(cx).value();
if !passphrase.is_empty() {
request = request.with_passphrase(passphrase.to_string());
}
}
Ok(request)
}
/// Log in with the form's current values without saving them, and report
/// the outcome as a notification. The login runs on a thread of its own;
/// a host key seen for the first time is put to the user in a dialog
/// above this one.
fn test_connection(&mut self, _: &ClickEvent, window: &mut Window, cx: &mut Context<Self>) {
if self.testing_connection {
return;
}
let host = self.host.read(cx).value().trim().to_string();
let port = self.port.read(cx).value().trim().parse::<u16>();
let error = if host.is_empty() {
Some("请输入主机")
} else if !matches!(port, Ok(1..=u16::MAX)) {
Some("端口必须是 1 到 65535 之间的数字")
} else {
None
let request = match self.login_test(cx) {
Ok(request) => request,
Err(reason) => {
window.push_notification(connection_test_notification(Err(reason.into())), cx);
return;
}
};
if let Some(error) = error {
self.connection_test_result = Some((false, error.into()));
cx.notify();
let (trust_tx, trust_rx) = std::sync::mpsc::channel::<TrustQuestion>();
let (result_tx, result_rx) = std::sync::mpsc::channel();
let tester = self.tester.clone();
let spawned = std::thread::Builder::new()
.name("shellr-connection-test".into())
.spawn(move || {
// Nobody left to answer (the form closed) reads as "no".
let trust: TrustCallback = Box::new(move |prompt| {
let (reply_tx, reply_rx) = std::sync::mpsc::channel();
trust_tx.send((prompt, reply_tx)).is_ok() && reply_rx.recv().unwrap_or(false)
});
let _ = result_tx.send(tester.test(request, trust));
});
if let Err(error) = spawned {
window.push_notification(
connection_test_notification(Err(format!("无法启动连接测试:{error}"))),
cx,
);
return;
}
let port = port.expect("validated port");
let endpoint = format!("{host}:{port}");
self.testing_connection = true;
self.connection_test_result = None;
cx.notify();
cx.spawn(async move |this, cx| {
let result = cx
.background_executor()
.spawn(async move { probe_ssh_service(&host, port) })
.await;
this.update_in(cx, |this, _, cx| {
this.testing_connection = false;
this.connection_test_result = Some(match result {
Ok(()) => (
true,
format!("{endpoint}:SSH 服务可达,尚未验证登录认证").into(),
),
Err(error) => (false, format!("{endpoint}:{error}").into()),
});
cx.notify();
})
.ok();
// Polled rather than woken by the worker, as with every other worker
// in the application.
cx.spawn_in(window, async move |this, cx| {
loop {
cx.background_executor()
.timer(Duration::from_millis(50))
.await;
while let Ok((prompt, reply)) = trust_rx.try_recv() {
if this
.update_in(cx, |_, window, cx| ask_to_trust(prompt, reply, window, cx))
.is_err()
{
return;
}
}
let result = match result_rx.try_recv() {
Ok(result) => result,
Err(std::sync::mpsc::TryRecvError::Empty) => {
if this.update(cx, |_, _| ()).is_err() {
return;
}
continue;
}
Err(std::sync::mpsc::TryRecvError::Disconnected) => {
Err("连接测试意外中止".to_string())
}
};
this.update_in(cx, |this, window, cx| {
this.testing_connection = false;
window.push_notification(connection_test_notification(result), cx);
cx.notify();
})
.ok();
return;
}
})
.detach();
}
@@ -555,70 +629,64 @@ impl Render for SessionForm {
.child(error),
)
})
.when_some(
self.connection_test_result.clone(),
|form, (success, message)| {
form.child(
div()
.id("connection-test-result")
.test_support()
.aria_label(message.clone())
.text_sm()
.text_color(if success {
cx.theme().success
} else {
cx.theme().danger
})
.child(message),
)
},
)
}
}
/// Check that a TCP endpoint speaks SSH. Authentication is intentionally not
/// attempted, so the form can test unsaved hosts without storing credentials
/// or changing the application's trusted-host list.
fn probe_ssh_service(host: &str, port: u16) -> Result<(), String> {
let runtime = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.map_err(|error| format!("无法启动连接测试:{error}"))?;
runtime.block_on(async {
tokio::time::timeout(Duration::from_secs(8), async {
let mut stream = tokio::net::TcpStream::connect((host, port))
.await
.map_err(|error| format!("无法连接:{error}"))?;
stream
.write_all(b"SSH-2.0-shellr-probe\r\n")
.await
.map_err(|error| format!("无法发送 SSH 标识:{error}"))?;
for _ in 0..10 {
let mut line = Vec::new();
for _ in 0..255 {
let byte = stream
.read_u8()
.await
.map_err(|error| format!("无法读取 SSH 标识:{error}"))?;
if byte == b'\n' {
break;
}
line.push(byte);
}
if line.starts_with(b"SSH-2.0-") || line.starts_with(b"SSH-1.99-") {
return Ok(());
}
}
Err("目标端口没有返回 SSH 服务标识".to_string())
})
.await
.map_err(|_| "连接超时".to_string())?
})
/// A trust question from the test's worker, with where to send the answer.
type TrustQuestion = (UnknownHostPrompt, std::sync::mpsc::Sender<bool>);
/// A port field's value, when it is one.
fn parse_port(port: &str) -> Option<u16> {
port.parse::<u16>().ok().filter(|port| *port != 0)
}
/// Put a first-seen host key to the user, above the session dialog. Closing
/// the dialog any other way than trusting counts as declining.
fn ask_to_trust(
prompt: UnknownHostPrompt,
reply: std::sync::mpsc::Sender<bool>,
window: &mut Window,
cx: &mut App,
) {
let description = prompt.description();
window.open_alert_dialog(cx, move |alert, _, _| {
let answer = |trusted: bool| {
let reply = reply.clone();
move || {
let _ = reply.send(trusted);
}
};
let (trust, decline, dismiss) = (answer(true), answer(false), answer(false));
alert
.title("首次连接此主机")
.description(description.clone())
.button_props(
DialogButtonProps::default()
.ok_text("信任并继续")
.cancel_text("取消"),
)
.show_cancel(true)
.on_ok(move |_, _, _| {
trust();
true
})
.on_cancel(move |_, _, _| {
decline();
true
})
.on_close(move |_, _, _| dismiss())
});
}
/// A connection test has two outcomes: it connected, or it did not and the
/// message says why.
fn connection_test_notification(result: Result<(), String>) -> Notification {
match result {
Ok(()) => Notification::success("连接成功"),
Err(reason) => Notification::error(reason).title("连接失败"),
}
}
/// Open the new-session (`editing == None`) or edit-session dialog.
/// `preselect_group` fills in the group field of a new session, so creating
/// one from a group's context menu lands it in that group.
/// Whether this authentication kind can end up asking for a password.
/// `Auto` walks agent, then keys, then password, so it can.
fn uses_password(auth: AuthKind) -> bool {
@@ -641,14 +709,19 @@ fn secret_change(value: String, loaded: bool) -> Option<Option<String>> {
}
}
/// Open the new-session (`editing == None`) or edit-session dialog.
/// `preselect_group` fills in the group field of a new session, so creating
/// one from a group's context menu lands it in that group. `tester` backs the
/// dialog's 「测试连接」 button.
pub fn open_session_dialog(
editing: Option<SessionId>,
preselect_group: Option<GroupId>,
store: Entity<SessionStore>,
tester: SharedConnectionTester,
window: &mut Window,
cx: &mut App,
) {
let form = cx.new(|cx| SessionForm::new(editing, preselect_group, store, window, cx));
let form = cx.new(|cx| SessionForm::new(editing, preselect_group, store, tester, window, cx));
let title: SharedString = if editing.is_some() {
"编辑会话"
} else {
@@ -759,7 +832,16 @@ pub fn confirm_delete_session(
#[cfg(test)]
mod tests {
use super::{AuthKind, secret_change, uses_password};
use super::{AuthKind, parse_port, secret_change, uses_password};
#[test]
fn a_port_field_is_a_number_from_1_to_65535() {
assert_eq!(parse_port("22"), Some(22));
assert_eq!(parse_port("65535"), Some(65535));
for port in ["0", "65536", "ssh", ""] {
assert_eq!(parse_port(port), None, "{port:?}");
}
}
#[test]
fn auto_and_password_can_reach_a_password_prompt() {
+123 -15
View File
@@ -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!("交互式认证已取消")
+2
View File
@@ -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;
+309
View File
@@ -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
View File
@@ -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 {
+37 -10
View File
@@ -28,6 +28,7 @@ use crate::app::{
ToggleSessionPanel, ToggleTheme, ToggleUploadSelection, UploadSelectedFiles, ZoomIn, ZoomOut,
ZoomReset,
};
use crate::connection::SharedConnectionTester;
use crate::explorer::{ExplorerPanel, ExplorerPanelEvent, confirm_close_upload};
use crate::session::{
ConnectionState, GroupId, SessionId, SessionPanel, SessionStore, SessionStoreEvent,
@@ -100,6 +101,8 @@ pub struct Workspace {
remote_terminal_provider: SharedRemoteTerminalTransportProvider,
sftp_provider: SharedSftpTransportProvider,
local_directory_provider: SharedLocalDirectoryProvider,
/// Backs the session dialog's 「测试连接」.
connection_tester: SharedConnectionTester,
next_remote_terminal_id: u64,
next_local_terminal_id: u64,
/// The center tab displayed most recently; `CloseActiveTab` closes it.
@@ -132,6 +135,7 @@ impl Workspace {
let remote = Arc::new(crate::ssh::SshTerminalTransportProvider::with_connector(
connector.clone(),
));
let tester = Arc::new(crate::ssh::SshConnectionTester::new(connector.clone()));
let sftp = Arc::new(SshSftpTransportProvider::new(
connector,
crate::app::data_dir().join("upload-resume"),
@@ -142,6 +146,7 @@ impl Workspace {
local_terminal_factory,
sftp,
Arc::new(SystemLocalDirectoryProvider),
tester,
window,
cx,
)
@@ -160,6 +165,7 @@ impl Workspace {
crate::app::data_dir().join("known_hosts"),
store.read(cx).secrets(),
);
let tester = Arc::new(crate::ssh::SshConnectionTester::new(connector.clone()));
let sftp = Arc::new(SshSftpTransportProvider::new(
connector,
crate::app::data_dir().join("upload-resume"),
@@ -170,18 +176,23 @@ impl Workspace {
local_terminal_factory,
sftp,
Arc::new(SystemLocalDirectoryProvider),
tester,
window,
cx,
)
}
/// Inject every filesystem and transport service; tests require neither a server nor a keychain.
// One parameter per injected service, which is the point of this
// constructor; bundling them would only move the list elsewhere.
#[allow(clippy::too_many_arguments)]
pub fn new_with_services(
store: Entity<SessionStore>,
remote_terminal_provider: SharedRemoteTerminalTransportProvider,
local_terminal_factory: SharedTerminalTransportFactory,
sftp_provider: SharedSftpTransportProvider,
local_directory_provider: SharedLocalDirectoryProvider,
connection_tester: SharedConnectionTester,
window: &mut Window,
cx: &mut Context<Self>,
) -> Self {
@@ -303,6 +314,7 @@ impl Workspace {
remote_terminal_provider,
sftp_provider,
local_directory_provider,
connection_tester,
next_remote_terminal_id,
next_local_terminal_id: 1,
active_tab: None,
@@ -408,13 +420,7 @@ impl Workspace {
let workspace = cx.entity().downgrade();
match prompt.kind().clone() {
TerminalPromptKind::UnknownHost(prompt) => {
let description = format!(
"主机:{}:{}\n算法:{}\nSHA-256 指纹:{}\n\n请先确认该指纹来自可信渠道。",
prompt.host(),
prompt.port(),
prompt.algorithm(),
prompt.fingerprint(),
);
let description = prompt.description();
window.open_alert_dialog(cx, move |alert, _, _| {
alert
.title("首次连接此主机")
@@ -1154,7 +1160,14 @@ impl Workspace {
}
fn on_new_session(&mut self, _: &NewSession, window: &mut Window, cx: &mut Context<Self>) {
open_session_dialog(None, None, self.store.clone(), window, cx);
open_session_dialog(
None,
None,
self.store.clone(),
self.connection_tester.clone(),
window,
cx,
);
}
fn on_edit_session(
@@ -1164,7 +1177,14 @@ impl Workspace {
cx: &mut Context<Self>,
) {
if self.store.read(cx).session(action.0).is_some() {
open_session_dialog(Some(action.0), None, self.store.clone(), window, cx);
open_session_dialog(
Some(action.0),
None,
self.store.clone(),
self.connection_tester.clone(),
window,
cx,
);
}
}
@@ -1268,7 +1288,14 @@ impl Workspace {
window: &mut Window,
cx: &mut Context<Self>,
) {
open_session_dialog(None, Some(action.0), self.store.clone(), window, cx);
open_session_dialog(
None,
Some(action.0),
self.store.clone(),
self.connection_tester.clone(),
window,
cx,
);
}
fn on_new_group(&mut self, _: &NewGroup, window: &mut Window, cx: &mut Context<Self>) {