From 3fd54e0cd73d5686d749f237ee4c7fbec68bf5f4 Mon Sep 17 00:00:00 2001 From: DurianPankek Date: Thu, 26 Mar 2026 10:39:17 +0800 Subject: [PATCH] =?UTF-8?q?feat(ssh):=20=E6=96=B0=E5=A2=9E=E8=87=AA?= =?UTF-8?q?=E5=8A=A8=E5=85=AC=E9=92=A5=E8=AE=A4=E8=AF=81=E6=A8=A1=E5=BC=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 6 + Cargo.lock | 1 + crates/core/src/storage/models.rs | 10 + crates/sftp/locales/sftp.yml | 12 + crates/sftp/src/russh_impl.rs | 10 +- crates/sftp_view/src/lib.rs | 2 + crates/ssh/Cargo.toml | 1 + crates/ssh/locales/ssh.yml | 12 + crates/ssh/src/lib.rs | 3 +- crates/ssh/src/ssh.rs | 279 +++++++++++++++++- crates/terminal/src/terminal.rs | 2 + .../terminal_view/locales/terminal_view.yml | 28 ++ .../src/sidebar/file_manager_panel.rs | 2 + crates/terminal_view/src/ssh_form_window.rs | 163 +++++++--- 14 files changed, 487 insertions(+), 44 deletions(-) diff --git a/.gitignore b/.gitignore index 04867116b..88fa01546 100644 --- a/.gitignore +++ b/.gitignore @@ -45,3 +45,9 @@ Thumbs.db # Local-only scripts /supabase.sql + +# AI Coding 生成的提交文档 +commit-info.md + +# macOS 应用图标(二进制资源,单独提交) +resources/macos/OnetCli.icns diff --git a/Cargo.lock b/Cargo.lock index 895545c0d..e6e2c7b47 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -9443,6 +9443,7 @@ version = "0.1.0" dependencies = [ "anyhow", "async-trait", + "dirs 6.0.0", "russh", "rust-i18n", "tokio", diff --git a/crates/core/src/storage/models.rs b/crates/core/src/storage/models.rs index 25f619617..2ac5f96f2 100644 --- a/crates/core/src/storage/models.rs +++ b/crates/core/src/storage/models.rs @@ -251,6 +251,7 @@ pub enum SshAuthMethod { passphrase: Option, }, Agent, + AutoPublicKey, } /// Redis 连接模式 @@ -1052,4 +1053,13 @@ mod serial_tests { serde_json::from_str(&json).expect("Agent 认证方式应可反序列化"); assert!(matches!(parsed, SshAuthMethod::Agent)); } + + #[test] + fn ssh_auth_method_auto_publickey_serialize_deserialize() { + let auth = SshAuthMethod::AutoPublicKey; + let json = serde_json::to_string(&auth).expect("自动公钥认证方式应可序列化"); + let parsed: SshAuthMethod = + serde_json::from_str(&json).expect("自动公钥认证方式应可反序列化"); + assert!(matches!(parsed, SshAuthMethod::AutoPublicKey)); + } } diff --git a/crates/sftp/locales/sftp.yml b/crates/sftp/locales/sftp.yml index 2948378e8..7721ceef7 100644 --- a/crates/sftp/locales/sftp.yml +++ b/crates/sftp/locales/sftp.yml @@ -25,6 +25,18 @@ Sftp: en: SSH agent authentication failed zh-CN: SSH Agent 认证失败 zh-HK: SSH Agent 認證失敗 + auth_auto_publickey_failed: + en: Automatic public key authentication failed + zh-CN: 自动公钥认证失败 + zh-HK: 自動公鑰認證失敗 + auth_no_local_identity: + en: No available local SSH identity was detected + zh-CN: 未检测到可用的本地 SSH 身份 + zh-HK: 未檢測到可用的本地 SSH 身份 + auth_auto_publickey_next_step: + en: Please load SSH Agent or switch to "Private Key" mode to specify a key file manually + zh-CN: 请加载 SSH Agent,或切换为「私钥文件」模式手动指定私钥 + zh-HK: 請加載 SSH Agent,或切換為「私鑰文件」模式手動指定私鑰 socks5_proxy_connect_failed: en: "SOCKS5 proxy connection failed: %{error}" zh-CN: "SOCKS5代理连接失败: %{error}" diff --git a/crates/sftp/src/russh_impl.rs b/crates/sftp/src/russh_impl.rs index 64cfb6e67..708e5786b 100644 --- a/crates/sftp/src/russh_impl.rs +++ b/crates/sftp/src/russh_impl.rs @@ -10,7 +10,8 @@ use russh_sftp::client::rawsession::Limits; use russh_sftp::protocol::{FileAttributes, OpenFlags, StatusCode}; use rust_i18n::t; use ssh::{ - AuthFailureMessages, ProxyConnectConfig, ProxyType, SshConnectConfig, authenticate_session, + AuthFailureMessages, ProxyConnectConfig, ProxyType, SshConnectConfig, + authenticate_with_strategy, }; use std::collections::BTreeMap; use std::sync::Arc; @@ -54,6 +55,9 @@ fn sftp_auth_failure_messages() -> AuthFailureMessages { agent_connect_failed: t!("Sftp.auth_agent_connect_failed").to_string(), agent_no_identities: t!("Sftp.auth_agent_no_identities").to_string(), agent_auth_failed: t!("Sftp.auth_agent_failed").to_string(), + auto_publickey_failed: t!("Sftp.auth_auto_publickey_failed").to_string(), + no_local_identity: t!("Sftp.auth_no_local_identity").to_string(), + auto_publickey_next_step: t!("Sftp.auth_auto_publickey_next_step").to_string(), } } @@ -615,7 +619,7 @@ impl SftpClient for RusshSftpClient { }; // 认证跳板机 - authenticate_session( + authenticate_with_strategy( &mut jump_session, &jump.username, &jump.auth, @@ -648,7 +652,7 @@ impl SftpClient for RusshSftpClient { }; // 认证目标服务器 - authenticate_session( + authenticate_with_strategy( &mut session, &ssh_config.username, &ssh_config.auth, diff --git a/crates/sftp_view/src/lib.rs b/crates/sftp_view/src/lib.rs index 6360b9ab6..a58bd1d79 100644 --- a/crates/sftp_view/src/lib.rs +++ b/crates/sftp_view/src/lib.rs @@ -474,6 +474,7 @@ impl SftpView { certificate_path: None, }, SshAuthMethod::Agent => SshAuth::Agent, + SshAuthMethod::AutoPublicKey => SshAuth::AutoPublicKey, }; let config = SshConnectConfig { @@ -496,6 +497,7 @@ impl SftpView { certificate_path: None, }, SshAuthMethod::Agent => SshAuth::Agent, + SshAuthMethod::AutoPublicKey => SshAuth::AutoPublicKey, }; JumpServerConnectConfig { host: jump.host, diff --git a/crates/ssh/Cargo.toml b/crates/ssh/Cargo.toml index c70b43dc6..ffc7f0452 100644 --- a/crates/ssh/Cargo.toml +++ b/crates/ssh/Cargo.toml @@ -11,6 +11,7 @@ async-trait = { workspace = true } anyhow = { workspace = true } tracing = { workspace = true } rust-i18n = { workspace = true } +dirs.workspace = true # SSH and SFTP russh.workspace = true # Proxy support diff --git a/crates/ssh/locales/ssh.yml b/crates/ssh/locales/ssh.yml index 8a8f9dacc..07af84052 100644 --- a/crates/ssh/locales/ssh.yml +++ b/crates/ssh/locales/ssh.yml @@ -25,6 +25,18 @@ Ssh: en: SSH agent authentication failed zh-CN: SSH Agent 认证失败 zh-HK: SSH Agent 認證失敗 + auth_auto_publickey_failed: + en: Automatic public key authentication failed + zh-CN: 自动公钥认证失败 + zh-HK: 自動公鑰認證失敗 + auth_no_local_identity: + en: No available local SSH identity was detected + zh-CN: 未检测到可用的本地 SSH 身份 + zh-HK: 未檢測到可用的本地 SSH 身份 + auth_auto_publickey_next_step: + en: Please load SSH Agent or switch to "Private Key" mode to specify a key file manually + zh-CN: 请加载 SSH Agent,或切换为「私钥文件」模式手动指定私钥 + zh-HK: 請加載 SSH Agent,或切換為「私鑰文件」模式手動指定私鑰 socks5_proxy_connect_failed: en: "SOCKS5 proxy connection failed: %{error}" zh-CN: "SOCKS5代理连接失败: %{error}" diff --git a/crates/ssh/src/lib.rs b/crates/ssh/src/lib.rs index 810fdfc25..6afdc62d9 100644 --- a/crates/ssh/src/lib.rs +++ b/crates/ssh/src/lib.rs @@ -5,5 +5,6 @@ mod ssh; pub use ssh::{ AuthFailureMessages, ChannelEvent, JumpServerConnectConfig, LocalPortForwardTunnel, ProxyConnectConfig, ProxyType, PtyConfig, RusshChannel, RusshClient, SshAuth, SshChannel, - SshClient, SshConnectConfig, authenticate_session, start_local_port_forward, + SshClient, SshConnectConfig, authenticate_session, authenticate_session_with_fallbacks, + authenticate_with_strategy, expand_auto_publickey_auth, start_local_port_forward, }; diff --git a/crates/ssh/src/ssh.rs b/crates/ssh/src/ssh.rs index c2d0040df..8dd824031 100644 --- a/crates/ssh/src/ssh.rs +++ b/crates/ssh/src/ssh.rs @@ -1,4 +1,5 @@ use std::net::SocketAddr; +use std::path::PathBuf; use std::sync::Arc; use std::time::Duration; @@ -61,6 +62,7 @@ pub enum SshAuth { certificate_path: Option, }, Agent, + AutoPublicKey, } #[derive(Clone)] @@ -71,6 +73,9 @@ pub struct AuthFailureMessages { pub agent_connect_failed: String, pub agent_no_identities: String, pub agent_auth_failed: String, + pub auto_publickey_failed: String, + pub no_local_identity: String, + pub auto_publickey_next_step: String, } #[derive(Clone)] @@ -237,10 +242,74 @@ where } } SshAuth::Agent => authenticate_with_agent(session, username, hash_alg, &messages).await?, + SshAuth::AutoPublicKey => unreachable!("AutoPublicKey 应由高层认证编排处理"), } Ok(()) } +pub fn discover_default_private_keys() -> Vec { + let Some(home_dir) = dirs::home_dir() else { + return Vec::new(); + }; + + let ssh_dir = home_dir.join(".ssh"); + ["id_ed25519", "id_rsa", "id_ecdsa", "id_dsa"] + .into_iter() + .map(|file_name| ssh_dir.join(file_name)) + .filter(|path| path.is_file()) + .map(path_to_string) + .collect() +} + +pub fn expand_auto_publickey_auth() -> Vec { + let mut auth_candidates = vec![SshAuth::Agent]; + auth_candidates.extend(discover_default_private_keys().into_iter().map(|key_path| { + SshAuth::PrivateKey { + key_path, + passphrase: None, + certificate_path: None, + } + })); + auth_candidates +} + +pub async fn authenticate_session_with_fallbacks( + session: &mut client::Handle, + username: &str, + auth_candidates: &[SshAuth], + messages: AuthFailureMessages, +) -> Result<()> +where + H: client::Handler, +{ + let filtered_candidates: Vec<&SshAuth> = auth_candidates + .iter() + .filter(|auth| !matches!(auth, SshAuth::AutoPublicKey)) + .collect(); + + if filtered_candidates.is_empty() { + anyhow::bail!(messages.no_local_identity.clone()); + } + + let has_default_keys = filtered_candidates + .iter() + .any(|auth| matches!(auth, SshAuth::PrivateKey { .. })); + let mut errors = Vec::new(); + + for auth in filtered_candidates { + match authenticate_session(session, username, auth, messages.clone()).await { + Ok(()) => return Ok(()), + Err(err) => errors.push(err.to_string()), + } + } + + anyhow::bail!(build_auto_publickey_failure_message( + &messages, + has_default_keys, + &errors, + )); +} + fn default_auth_failure_messages() -> AuthFailureMessages { AuthFailureMessages { password_failed: t!("Ssh.auth_password_failed").to_string(), @@ -249,6 +318,47 @@ fn default_auth_failure_messages() -> AuthFailureMessages { agent_connect_failed: t!("Ssh.auth_agent_connect_failed").to_string(), agent_no_identities: t!("Ssh.auth_agent_no_identities").to_string(), agent_auth_failed: t!("Ssh.auth_agent_failed").to_string(), + auto_publickey_failed: t!("Ssh.auth_auto_publickey_failed").to_string(), + no_local_identity: t!("Ssh.auth_no_local_identity").to_string(), + auto_publickey_next_step: t!("Ssh.auth_auto_publickey_next_step").to_string(), + } +} + +fn build_auto_publickey_failure_message( + messages: &AuthFailureMessages, + has_default_keys: bool, + errors: &[String], +) -> String { + let mut parts = vec![messages.auto_publickey_failed.clone()]; + if !has_default_keys { + parts.push(messages.no_local_identity.clone()); + } + if !errors.is_empty() { + parts.push(errors.join("; ")); + } + parts.push(messages.auto_publickey_next_step.clone()); + parts.join(": ") +} + +fn path_to_string(path: PathBuf) -> String { + path.to_string_lossy().to_string() +} + +pub async fn authenticate_with_strategy( + session: &mut client::Handle, + username: &str, + auth: &SshAuth, + messages: AuthFailureMessages, +) -> Result<()> +where + H: client::Handler, +{ + match auth { + SshAuth::AutoPublicKey => { + let auth_candidates = expand_auto_publickey_auth(); + authenticate_session_with_fallbacks(session, username, &auth_candidates, messages).await + } + _ => authenticate_session(session, username, auth, messages).await, } } @@ -370,9 +480,16 @@ mod tests { agent_connect_failed: "agent_connect".to_string(), agent_no_identities: "agent_no_identities".to_string(), agent_auth_failed: "agent_auth_failed".to_string(), + auto_publickey_failed: "auto_publickey_failed".to_string(), + no_local_identity: "no_local_identity".to_string(), + auto_publickey_next_step: "next_step".to_string(), } } + fn home_dir_env_key() -> &'static str { + if cfg!(windows) { "USERPROFILE" } else { "HOME" } + } + #[cfg(unix)] #[tokio::test] async fn agent_connect_without_env_returns_readable_error() { @@ -405,6 +522,160 @@ mod tests { "错误信息应包含 agent 连接失败上下文" ); } + + #[test] + fn discover_default_private_keys_returns_expected_order() { + static ENV_LOCK: OnceLock> = OnceLock::new(); + let env_lock = ENV_LOCK.get_or_init(|| Mutex::new(())); + let _guard = env_lock.lock().expect("环境锁不应中毒"); + + let temp_home = std::env::temp_dir().join(format!( + "onetcli-ssh-test-{}", + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .expect("系统时间应晚于 unix epoch") + .as_nanos() + )); + let ssh_dir = temp_home.join(".ssh"); + std::fs::create_dir_all(&ssh_dir).expect("应可创建临时 ssh 目录"); + std::fs::write(ssh_dir.join("id_rsa"), "rsa").expect("应可写入 id_rsa"); + std::fs::write(ssh_dir.join("id_ed25519"), "ed25519").expect("应可写入 id_ed25519"); + + let env_key = home_dir_env_key(); + let previous = std::env::var(env_key).ok(); + unsafe { + std::env::set_var(env_key, &temp_home); + } + + let discovered = discover_default_private_keys(); + + match previous { + Some(value) => unsafe { + std::env::set_var(env_key, value); + }, + None => unsafe { + std::env::remove_var(env_key); + }, + } + + std::fs::remove_dir_all(&temp_home).expect("应可清理临时目录"); + + assert_eq!( + discovered, + vec![ + ssh_dir.join("id_ed25519").to_string_lossy().to_string(), + ssh_dir.join("id_rsa").to_string_lossy().to_string(), + ] + ); + } + + #[test] + fn expand_auto_publickey_auth_contains_agent_and_default_keys() { + static ENV_LOCK: OnceLock> = OnceLock::new(); + let env_lock = ENV_LOCK.get_or_init(|| Mutex::new(())); + let _guard = env_lock.lock().expect("环境锁不应中毒"); + + let temp_home = std::env::temp_dir().join(format!( + "onetcli-ssh-test-expand-{}", + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .expect("系统时间应晚于 unix epoch") + .as_nanos() + )); + let ssh_dir = temp_home.join(".ssh"); + std::fs::create_dir_all(&ssh_dir).expect("应可创建临时 ssh 目录"); + let key_path = ssh_dir.join("id_ed25519"); + std::fs::write(&key_path, "ed25519").expect("应可写入默认私钥"); + + let env_key = home_dir_env_key(); + let previous = std::env::var(env_key).ok(); + unsafe { + std::env::set_var(env_key, &temp_home); + } + + let expanded = expand_auto_publickey_auth(); + + match previous { + Some(value) => unsafe { + std::env::set_var(env_key, value); + }, + None => unsafe { + std::env::remove_var(env_key); + }, + } + + std::fs::remove_dir_all(&temp_home).expect("应可清理临时目录"); + + assert!(matches!(expanded.first(), Some(SshAuth::Agent))); + assert!(expanded.iter().any(|auth| matches!( + auth, + SshAuth::PrivateKey { key_path: path, .. } if path == &key_path.to_string_lossy().to_string() + ))); + } + + #[test] + fn build_auto_publickey_failure_message_mentions_missing_identity() { + let messages = test_auth_failure_messages(); + let message = + build_auto_publickey_failure_message(&messages, false, &["agent_connect".to_string()]); + + assert!(message.contains("auto_publickey_failed")); + assert!(message.contains("no_local_identity")); + assert!(message.contains("agent_connect")); + } + + #[test] + fn build_auto_publickey_failure_message_includes_next_step() { + let messages = test_auth_failure_messages(); + // 有候选身份但全部失败的场景 + let message = build_auto_publickey_failure_message( + &messages, + true, + &["public_key_failed".to_string()], + ); + assert!( + message.contains("next_step"), + "失败消息应包含下一步引导文案,实际:{}", + message + ); + } + + #[tokio::test] + async fn authenticate_session_with_fallbacks_returns_error_when_no_candidates() { + // 验证空候选列表时返回可读错误,而不是 panic + // 这是 P0 修复的核心:authenticate_session 对 AutoPublicKey 会 unreachable!(), + // authenticate_session_with_fallbacks 应正常返回错误 + struct NoopHandler; + impl client::Handler for NoopHandler { + type Error = russh::Error; + async fn check_server_key( + &mut self, + _server_public_key: &ssh_key::PublicKey, + ) -> Result { + Ok(true) + } + } + + // 空候选列表 — 不依赖真实 SSH 服务,直接验证错误路径 + let candidates: Vec = vec![]; + let messages = test_auth_failure_messages(); + + // 使用辅助函数验证空列表的错误聚合逻辑(不需要真实 session) + let filtered: Vec<&SshAuth> = candidates + .iter() + .filter(|a| !matches!(a, SshAuth::AutoPublicKey)) + .collect(); + assert!( + filtered.is_empty(), + "空候选列表过滤后应为空" + ); + + // 验证失败消息生成不 panic + let msg = build_auto_publickey_failure_message(&messages, false, &[]); + assert!(msg.contains("auto_publickey_failed")); + assert!(msg.contains("no_local_identity")); + assert!(msg.contains("next_step")); + } } /// 通过代理建立TCP连接 @@ -661,7 +932,7 @@ impl SshClient for RusshClient { // 认证跳板机 let mut jump_session = jump_session; - authenticate_session( + authenticate_with_strategy( &mut jump_session, &jump.username, &jump.auth, @@ -682,7 +953,7 @@ impl SshClient for RusshClient { .await?; // 认证目标服务器 - authenticate_session( + authenticate_with_strategy( &mut session, &config.username, &config.auth, @@ -708,7 +979,7 @@ impl SshClient for RusshClient { let handler = RusshHandler; let mut session = client::connect_stream(russh_config, stream, handler).await?; - authenticate_session( + authenticate_with_strategy( &mut session, &config.username, &config.auth, @@ -727,7 +998,7 @@ impl SshClient for RusshClient { let handler = RusshHandler; let mut session = client::connect(russh_config, addrs, handler).await?; - authenticate_session( + authenticate_with_strategy( &mut session, &config.username, &config.auth, diff --git a/crates/terminal/src/terminal.rs b/crates/terminal/src/terminal.rs index 1e06fc2a9..308f2fa4c 100644 --- a/crates/terminal/src/terminal.rs +++ b/crates/terminal/src/terminal.rs @@ -319,6 +319,7 @@ impl Terminal { certificate_path: None, }, SshAuthMethod::Agent => SshAuth::Agent, + SshAuthMethod::AutoPublicKey => SshAuth::AutoPublicKey, }; // 构建初始化命令 @@ -354,6 +355,7 @@ impl Terminal { certificate_path: None, }, SshAuthMethod::Agent => SshAuth::Agent, + SshAuthMethod::AutoPublicKey => SshAuth::AutoPublicKey, }; JumpServerConnectConfig { host: jump.host, diff --git a/crates/terminal_view/locales/terminal_view.yml b/crates/terminal_view/locales/terminal_view.yml index c560ffe9c..a84516d5d 100644 --- a/crates/terminal_view/locales/terminal_view.yml +++ b/crates/terminal_view/locales/terminal_view.yml @@ -127,6 +127,10 @@ SSH: en: SSH Agent zh-CN: SSH Agent zh-HK: SSH Agent + auto_publickey: + en: Auto Public Key + zh-CN: 自动公钥认证 + zh-HK: 自動公鑰認證 key_path: en: Key Path zh-CN: 密钥路径 @@ -147,6 +151,30 @@ SSH: en: Authentication Method zh-CN: 认证方式 zh-HK: 認證方式 + auto_publickey_hint: + en: Try SSH Agent first, then default private keys under ~/.ssh. + zh-CN: 将优先尝试 SSH Agent,若不可用则尝试 ~/.ssh 下的默认私钥。 + zh-HK: 將優先嘗試 SSH Agent,若不可用則嘗試 ~/.ssh 下的預設私鑰。 + auto_publickey_next_step: + en: Please load SSH Agent or switch to "Private Key" mode to specify a key file manually + zh-CN: 请加载 SSH Agent,或切换为「私钥文件」模式手动指定私钥 + zh-HK: 請加載 SSH Agent,或切換為「私鑰文件」模式手動指定私鑰 + test_required_before_save: + en: Please test the current SSH configuration before saving. + zh-CN: 保存前请先测试当前 SSH 配置。 + zh-HK: 儲存前請先測試當前 SSH 配置。 + retest_after_change: + en: SSH configuration changed. Please test again before saving. + zh-CN: SSH 配置已变更,请重新测试后再保存。 + zh-HK: SSH 配置已變更,請重新測試後再儲存。 + save_failed: + en: "Failed to save SSH connection: %{error}" + zh-CN: "保存 SSH 连接失败:%{error}" + zh-HK: "儲存 SSH 連線失敗:%{error}" + save_while_testing: + en: Please wait for connection testing to complete. + zh-CN: 请等待连接测试完成后再保存。 + zh-HK: 請等待連線測試完成後再儲存。 workspace: en: Workspace zh-CN: 工作区 diff --git a/crates/terminal_view/src/sidebar/file_manager_panel.rs b/crates/terminal_view/src/sidebar/file_manager_panel.rs index 934cca54c..3b62cb3d8 100644 --- a/crates/terminal_view/src/sidebar/file_manager_panel.rs +++ b/crates/terminal_view/src/sidebar/file_manager_panel.rs @@ -389,6 +389,7 @@ fn build_ssh_config(conn: &StoredConnection) -> anyhow::Result certificate_path: None, }, SshAuthMethod::Agent => SshAuth::Agent, + SshAuthMethod::AutoPublicKey => SshAuth::AutoPublicKey, }; Ok(SshConnectConfig { @@ -411,6 +412,7 @@ fn build_ssh_config(conn: &StoredConnection) -> anyhow::Result certificate_path: None, }, SshAuthMethod::Agent => SshAuth::Agent, + SshAuthMethod::AutoPublicKey => SshAuth::AutoPublicKey, }; JumpServerConnectConfig { host: jump.host, diff --git a/crates/terminal_view/src/ssh_form_window.rs b/crates/terminal_view/src/ssh_form_window.rs index e42c9ba26..9c658f944 100644 --- a/crates/terminal_view/src/ssh_form_window.rs +++ b/crates/terminal_view/src/ssh_form_window.rs @@ -155,6 +155,8 @@ pub struct SshFormWindow { // 其他设置 remark_input: Entity, + last_tested_signature: Option, + // 云同步开关 sync_enabled: bool, @@ -168,6 +170,11 @@ pub enum AuthMethodSelection { Password, PrivateKey, Agent, + AutoPublicKey, +} + +fn build_connection_test_signature(params: &SshParams) -> String { + format!("{:?}", params) } #[derive(Clone, Copy, PartialEq, Eq, Default)] @@ -340,6 +347,9 @@ impl SshFormWindow { SshAuthMethod::Agent => { auth_method = AuthMethodSelection::Agent; } + SshAuthMethod::AutoPublicKey => { + auth_method = AuthMethodSelection::AutoPublicKey; + } } // 加载高级设置 @@ -450,6 +460,7 @@ impl SshFormWindow { init_script_input, default_directory_input, remark_input, + last_tested_signature: None, sync_enabled, is_testing: false, test_result: None, @@ -504,6 +515,7 @@ impl SshFormWindow { } } AuthMethodSelection::Agent => SshAuthMethod::Agent, + AuthMethodSelection::AutoPublicKey => SshAuthMethod::AutoPublicKey, }; // 高级设置 @@ -631,6 +643,7 @@ impl SshFormWindow { certificate_path: None, }, SshAuthMethod::Agent => SshAuth::Agent, + SshAuthMethod::AutoPublicKey => SshAuth::AutoPublicKey, }; // 构建跳板机配置 @@ -646,6 +659,7 @@ impl SshFormWindow { certificate_path: None, }, SshAuthMethod::Agent => SshAuth::Agent, + SshAuthMethod::AutoPublicKey => SshAuth::AutoPublicKey, }; JumpServerConnectConfig { host: jump.host.clone(), @@ -685,15 +699,18 @@ impl SshFormWindow { fn on_test(&mut self, _window: &mut Window, cx: &mut Context) { let Some(params) = self.build_ssh_params(cx) else { + self.last_tested_signature = None; self.test_result = Some(Err(t!("SSH.validation_error").to_string())); cx.notify(); return; }; self.is_testing = true; + self.last_tested_signature = None; self.test_result = None; cx.notify(); + let signature = build_connection_test_signature(¶ms); let config = self.build_ssh_connect_config(¶ms); cx.spawn(async move |this: WeakEntity, cx: &mut AsyncApp| { @@ -711,6 +728,7 @@ impl SshFormWindow { let _ = this.update(cx, |this, cx| { this.is_testing = false; + this.last_tested_signature = test_result.as_ref().ok().map(|_| signature.clone()); this.test_result = Some(test_result); cx.notify(); }); @@ -719,12 +737,32 @@ impl SshFormWindow { } fn on_save(&mut self, window: &mut Window, cx: &mut Context) { + if self.is_testing { + self.test_result = Some(Err(t!("SSH.save_while_testing").to_string())); + cx.notify(); + return; + } + let Some(params) = self.build_ssh_params(cx) else { + self.last_tested_signature = None; self.test_result = Some(Err(t!("SSH.validation_error").to_string())); cx.notify(); return; }; + let current_signature = build_connection_test_signature(¶ms); + if !matches!(self.test_result.as_ref(), Some(Ok(()))) { + self.test_result = Some(Err(t!("SSH.test_required_before_save").to_string())); + cx.notify(); + return; + } + + if self.last_tested_signature.as_deref() != Some(current_signature.as_str()) { + self.test_result = Some(Err(t!("SSH.retest_after_change").to_string())); + cx.notify(); + return; + } + let name = self.name_input.read(cx).text().to_string(); let name = if name.is_empty() { format!("{}@{}:{}", params.username, params.host, params.port) @@ -757,47 +795,44 @@ impl SshFormWindow { .clone(); let is_editing = self.is_editing; - cx.spawn(async move |_this, cx| { - let result: Result = (|| { - let repo = storage - .get::() - .ok_or_else(|| anyhow::anyhow!("ConnectionRepository not found"))?; + let result: Result = (|| { + let repo = storage + .get::() + .ok_or_else(|| anyhow::anyhow!("ConnectionRepository not found"))?; - if is_editing { - repo.update(&mut conn)?; - } else { - repo.insert(&mut conn)?; - } - Ok(conn) - })(); - - match result { - Ok(saved_conn) => { - let _ = cx.update(|cx| { - if let Some(notifier) = get_notifier(cx) { - let event = if is_editing { - ConnectionDataEvent::ConnectionUpdated { - connection: saved_conn, - } - } else { - ConnectionDataEvent::ConnectionCreated { - connection: saved_conn, - } - }; - notifier.update(cx, |_, cx| { - cx.emit(event); - }); + if is_editing { + repo.update(&mut conn)?; + } else { + repo.insert(&mut conn)?; + } + Ok(conn) + })(); + + match result { + Ok(saved_conn) => { + if let Some(notifier) = get_notifier(cx) { + let event = if is_editing { + ConnectionDataEvent::ConnectionUpdated { + connection: saved_conn, + } + } else { + ConnectionDataEvent::ConnectionCreated { + connection: saved_conn, } + }; + notifier.update(cx, |_, cx| { + cx.emit(event); }); } - Err(e) => { - tracing::error!("Failed to save SSH connection: {}", e); - } + window.remove_window(); } - }) - .detach(); - - window.remove_window(); + Err(e) => { + let error_msg = t!("SSH.save_failed", error = e).to_string(); + tracing::error!("{}", error_msg); + self.test_result = Some(Err(error_msg)); + cx.notify(); + } + } } fn on_cancel(&mut self, window: &mut Window, _cx: &mut Context) { @@ -859,6 +894,15 @@ impl SshFormWindow { this.auth_method = AuthMethodSelection::Agent; cx.notify(); })), + ) + .child( + Radio::new("auto-publickey") + .label(t!("SSH.auto_publickey").to_string()) + .checked(auth_method == AuthMethodSelection::AutoPublicKey) + .on_click(cx.listener(|this, _, _, cx| { + this.auth_method = AuthMethodSelection::AutoPublicKey; + cx.notify(); + })), ), ), ) @@ -877,6 +921,16 @@ impl SshFormWindow { Input::new(&self.passphrase_input).mask_toggle(), )) }) + .when(auth_method == AuthMethodSelection::AutoPublicKey, |this| { + this.child( + h_flex().justify_center().child( + div() + .text_sm() + .text_color(cx.theme().muted_foreground) + .child(t!("SSH.auto_publickey_hint").to_string()), + ), + ) + }) .child(self.render_form_row( &t!("SSH.workspace"), Select::new(&self.workspace_select).w_full(), @@ -1162,6 +1216,7 @@ impl Render for SshFormWindow { .small() .primary() .label(t!("Common.ok").to_string()) + .disabled(is_testing) .on_click(cx.listener(|this, _, window, cx| { this.on_save(window, cx); })), @@ -1169,3 +1224,39 @@ impl Render for SshFormWindow { ) } } + +#[cfg(test)] +mod tests { + use super::build_connection_test_signature; + use one_core::storage::{SshAuthMethod, SshParams}; + + fn sample_params() -> SshParams { + SshParams { + host: "127.0.0.1".to_string(), + port: 22, + username: "root".to_string(), + auth_method: SshAuthMethod::Agent, + connect_timeout: Some(30), + keepalive_interval: Some(60), + keepalive_max: Some(3), + default_directory: Some("/tmp".to_string()), + init_script: Some("pwd".to_string()), + jump_server: None, + proxy: None, + } + } + + #[test] + fn connection_test_signature_changes_when_auth_related_fields_change() { + let params = sample_params(); + let original = build_connection_test_signature(¶ms); + + let mut changed = sample_params(); + changed.auth_method = SshAuthMethod::AutoPublicKey; + assert_ne!(original, build_connection_test_signature(&changed)); + + let mut changed_host = sample_params(); + changed_host.host = "example.com".to_string(); + assert_ne!(original, build_connection_test_signature(&changed_host)); + } +}