diff --git a/locales/en.yml b/locales/en.yml index 3fd8331..3d144c6 100644 --- a/locales/en.yml +++ b/locales/en.yml @@ -179,3 +179,4 @@ sync_downloading: "Downloading and decrypting configuration..." sync_upload_complete: "Configuration uploaded" sync_download_complete: "Configuration downloaded" sync_failed: "Synchronization failed" +keyboard_interactive: "Keyboard Interactive" diff --git a/locales/zh-CN.yml b/locales/zh-CN.yml index d2137a2..06a1a79 100644 --- a/locales/zh-CN.yml +++ b/locales/zh-CN.yml @@ -181,3 +181,4 @@ sync_downloading: "正在下载并解密配置..." sync_upload_complete: "配置上传完成" sync_download_complete: "配置下载完成" sync_failed: "同步失败" +keyboard_interactive: "键盘交互" diff --git a/src/app/dialogs.rs b/src/app/dialogs.rs index 9d2141d..8fd5c3f 100644 --- a/src/app/dialogs.rs +++ b/src/app/dialogs.rs @@ -1,5 +1,5 @@ use gpui::{ - Anchor, Context, Focusable as _, FontWeight, InteractiveElement as _, MouseButton, + Anchor, AppContext as _, Context, Focusable as _, FontWeight, InteractiveElement as _, MouseButton, ParentElement as _, SharedString, StatefulInteractiveElement as _, Styled as _, Window, div, prelude::FluentBuilder as _, px, rems, }; @@ -8,7 +8,7 @@ use gpui_component::{ button::{Button, ButtonVariants as _}, dialog::Dialog, h_flex, - input::Input, + input::{Input, InputState}, menu::{DropdownMenu as _, PopupMenuItem}, progress::Progress, scroll::{Scrollbar, ScrollbarShow}, @@ -62,7 +62,10 @@ impl Ashell { let key_inline_input = key_inline_input.clone(); let passphrase_input = passphrase_input.clone(); move |content, window, cx| { - let is_password = view.read(cx).ssh_auth_method == AuthMethod::Password; + let method = view.read(cx).ssh_auth_method; + let is_password = method == AuthMethod::Password; + let is_key = method == AuthMethod::Key; + let is_kb = method == AuthMethod::KeyboardInteractive; let is_editing = view.read(cx).editing_session_id.is_some(); content.child( v_flex() @@ -89,13 +92,13 @@ impl Ashell { AuthMethod::Password, cx, ) - }, - )), + }, + )), ) .child( Button::new("ssh-auth-key") .label(t!("key").to_string()) - .when(!is_password, |button| button.primary()) + .when(is_key, |button| button.primary()) .on_click(window.listener_for( &view, |this, _, _, cx| { @@ -103,8 +106,22 @@ impl Ashell { AuthMethod::Key, cx, ) - }, - )), + }, + )), + ) + .child( + Button::new("ssh-auth-kb") + .label(t!("keyboard_interactive").to_string()) + .when(is_kb, |button| button.primary()) + .on_click(window.listener_for( + &view, + |this, _, _, cx| { + this.set_ssh_auth_method( + AuthMethod::KeyboardInteractive, + cx, + ) + }, + )), ), ) .when(is_password, |this| { @@ -112,7 +129,7 @@ impl Ashell { Input::new(&password_input).mask_toggle().tab_index(4), ) }) - .when(!is_password, |this| { + .when(is_key, |this| { this.child( h_flex() .gap_2() @@ -1761,7 +1778,7 @@ impl Ashell { ) .child( div() - .text_size(rems(0.9)) + .text_size(rems(0.9)) .text_color(cx.theme().muted_foreground) .child(t!("about_feedback_hint")), ) @@ -1782,4 +1799,101 @@ impl Ashell { }) }); } + + pub(crate) fn show_interactive_prompt_dialog( + &mut self, + tab_id: String, + prompt_type: crate::terminal::PromptType, + instruction: String, + prompts: Vec, + window: &mut Window, + cx: &mut Context, + ) { + let view = cx.entity(); + + // Dynamically instantiate InputState for each prompt + let mut input_states = Vec::new(); + for p in &prompts { + let is_masked = !p.echo; + let input_state = cx.new(|cx| { + let mut state = InputState::new(window, cx).placeholder(p.prompt.clone()); + if is_masked { + state = state.masked(true); + } + state + }); + input_states.push(input_state); + } + + let tab_id_clone = tab_id.clone(); + let input_states_clone = input_states.clone(); + + self.active_dialog = Some(crate::app::DialogKind::PromptRequest); + + let tab_id_for_close = tab_id.clone(); + + window.open_dialog(cx, move |dialog: Dialog, _window, _| { + let title = match prompt_type { + crate::terminal::PromptType::KeyboardInteractive => "Keyboard Interactive Authentication", + crate::terminal::PromptType::Passphrase => "Enter Private Key Passphrase", + }; + + let tab_id_for_ok = tab_id_clone.clone(); + let input_states_for_ok = input_states_clone.clone(); + let view_for_ok = view.clone(); + + let view_for_close = view.clone(); + let tab_id_for_close = tab_id_for_close.clone(); + + let instruction_for_content = instruction.clone(); + let input_states_for_content = input_states_clone.clone(); + + dialog + .title(title) + .w(px(500.)) + .overlay_closable(false) + .on_close(move |_, _, cx| { + view_for_close.update(cx, |this, cx| { + this.active_dialog = None; + // Send Close command to abort connection if they close the dialog without OK + if let Some(tab) = this.tabs.iter().find(|t| t.id == tab_id_for_close) { + tab.backend.send(crate::terminal::BackendCommand::Close); + } + cx.notify(); + }); + }) + .on_ok(move |_, window, cx| { + view_for_ok.update(cx, |this, cx| { + this.active_dialog = None; + let mut responses = Vec::new(); + for state in &input_states_for_ok { + responses.push(state.read(cx).text().to_string()); + } + if let Some(tab) = this.tabs.iter().find(|t| t.id == tab_id_for_ok) { + tab.backend.send(crate::terminal::BackendCommand::PromptResponse(responses)); + } + cx.notify(); + }); + window.close_dialog(cx); + true + }) + .content(move |content, _window, _cx| { + let mut container = v_flex().gap_3(); + if !instruction_for_content.is_empty() { + container = container.child( + div() + .text_sm() + .text_color(gpui::rgba(0x808080ff)) + .child(instruction_for_content.clone()) + ); + } + for input_state in &input_states_for_content { + container = container.child( + Input::new(input_state).w_full() + ); + } + content.child(container) + }) + }); + } } diff --git a/src/app/mod.rs b/src/app/mod.rs index e2aaf3e..7860ba0 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -196,6 +196,7 @@ pub(crate) enum DialogKind { SessionSelector, Transfers, NewSsh, + PromptRequest, } pub(crate) struct Ashell { @@ -604,7 +605,7 @@ impl Ashell { this.apply_theme_preferences(window, cx); // this.open_local(cx); - this.start_event_pump(cx); + this.start_event_pump(window, cx); this } @@ -654,17 +655,19 @@ impl Ashell { cx.notify(); } - pub(crate) fn start_event_pump(&self, cx: &mut Context) { - cx.spawn(async move |this, cx| { + pub(crate) fn start_event_pump(&self, window: &mut Window, cx: &mut Context) { + cx.spawn_in(window, async move |this, mut cx| { let mut idle_frames = 0u32; loop { cx.background_executor() .timer(Duration::from_millis(16)) .await; - if this - .update(cx, |this, cx| { - let changed = this.drain_backend_events(); - let system_sampled = this.sample_system_if_due(); + let mut changed = false; + let mut system_sampled = false; + let update_res = gpui::AsyncWindowContext::update(&mut cx, |window, cx| { + let _ = this.update(cx, |this, cx| { + changed = this.drain_backend_events(window, cx); + system_sampled = this.sample_system_if_due(); this.sync_theme_if_due(cx); if changed || system_sampled { cx.notify(); @@ -676,9 +679,9 @@ impl Ashell { idle_frames = 0; } } - }) - .is_err() - { + }); + }); + if update_res.is_err() { break; } } @@ -686,7 +689,7 @@ impl Ashell { .detach(); } - pub(crate) fn drain_backend_events(&mut self) -> bool { + pub(crate) fn drain_backend_events(&mut self, window: &mut Window, cx: &mut Context) -> bool { let mut changed = false; let mut transfers_changed = false; while let Ok(event) = self.events_rx.try_recv() { @@ -725,6 +728,21 @@ impl Ashell { self.connection_progress = None; } } + BackendEvent::PromptRequest { + tab_id, + prompt_type, + instruction, + prompts, + } => { + self.show_interactive_prompt_dialog( + tab_id, + prompt_type, + instruction, + prompts, + window, + cx, + ); + } BackendEvent::SftpEntries { tab_id, path, diff --git a/src/backend/local.rs b/src/backend/local.rs index 32d53b4..3c4aa9e 100644 --- a/src/backend/local.rs +++ b/src/backend/local.rs @@ -117,6 +117,7 @@ pub fn spawn_local_terminal( } BackendCommand::Close => break, BackendCommand::SampleMetrics => {} + BackendCommand::PromptResponse(_) => {} }, Err(mpsc::RecvTimeoutError::Timeout) => { if let Ok(Some(status)) = child.try_wait() { diff --git a/src/backend/ssh.rs b/src/backend/ssh.rs index f94fe38..0133eb0 100644 --- a/src/backend/ssh.rs +++ b/src/backend/ssh.rs @@ -16,8 +16,23 @@ use tokio::sync::mpsc; use crate::{ session::config::{AuthMethod, Session}, system::{SystemSnapshot, remote_snapshot_from_kv}, - terminal::{BackendCommand, BackendEvent, BackendTx}, + terminal::{BackendCommand, BackendEvent, BackendTx, PromptType, PromptInfo}, }; +use std::sync::OnceLock; +use std::collections::HashMap; +use tokio::sync::Mutex; + +#[allow(dead_code)] +#[derive(Debug, Clone)] +pub struct CachedCreds { + pub password: Option, + pub passphrase: Option, + pub kb_responses: Option>, +} + +pub static CREDENTIALS_CACHE: OnceLock>> = OnceLock::new(); + +pub static PROMPT_LOCK: OnceLock> = OnceLock::new(); pub fn spawn_ssh_terminal( runtime: &tokio::runtime::Handle, @@ -95,7 +110,7 @@ async fn run_ssh( }); let handle = Arc::new(tokio::sync::Mutex::new( - connect_and_authenticate(&tab_id, &session, &events).await?, + connect_and_authenticate(&tab_id, &session, &events, &mut commands).await?, )); let mut channel = handle @@ -156,6 +171,9 @@ async fn run_ssh( } }); } + Some(BackendCommand::PromptResponse(_)) => { + tracing::warn!("[ssh] received unexpected prompt response after authentication"); + } Some(BackendCommand::Close) | None => { tracing::info!("[ssh] local client closed the session for tab {}", tab_id); let _ = channel.eof().await; @@ -213,10 +231,27 @@ async fn run_ssh( Ok(()) } +async fn load_session_private_key_with_cache(session: &Session) -> Result { + let cached_passphrase = { + let cache_lock = CREDENTIALS_CACHE.get_or_init(|| std::sync::Mutex::new(HashMap::new())); + let cache = cache_lock.lock().unwrap(); + cache.get(&session.id).and_then(|c| c.passphrase.clone()) + }; + if let Some(p) = cached_passphrase { + let mut temp_session = session.clone(); + temp_session.passphrase = p; + if let Ok(key) = load_session_private_key(&temp_session) { + return Ok(key); + } + } + load_session_private_key(session) +} + async fn connect_and_authenticate( tab_id: &str, session: &Session, events: &std::sync::mpsc::Sender, + commands: &mut mpsc::UnboundedReceiver, ) -> Result> { let config = Arc::new(client::Config { inactivity_timeout: Some(std::time::Duration::from_secs(600)), @@ -271,7 +306,55 @@ async fn connect_and_authenticate( tab_id: tab_id.to_string(), text: format!("connected to {addr}, loading private key from {source}"), }); - let keypair = load_session_private_key(session)?; + let mut keypair = load_session_private_key_with_cache(session).await; + if let Err(e) = &keypair { + let err_str = e.to_string(); + if err_str.contains("encrypted") || err_str.contains("passphrase") || err_str.contains("decrypt") { + let prompt_lock = PROMPT_LOCK.get_or_init(|| Mutex::new(())); + let _guard = prompt_lock.lock().await; + let _ = events.send(BackendEvent::PromptRequest { + tab_id: tab_id.to_string(), + prompt_type: PromptType::Passphrase, + instruction: format!("Enter passphrase for private key (source: {})", source), + prompts: vec![PromptInfo { + prompt: "Passphrase".to_string(), + echo: false, + }], + }); + let mut passphrase_res = None; + while let Some(cmd) = commands.recv().await { + match cmd { + BackendCommand::PromptResponse(responses) => { + if let Some(p) = responses.first().cloned() { + passphrase_res = Some(p); + } + break; + } + BackendCommand::Close => { + return Err(anyhow!("Authentication cancelled by user")); + } + _ => {} + } + } + if let Some(p) = passphrase_res { + { + let cache_lock = CREDENTIALS_CACHE.get_or_init(|| std::sync::Mutex::new(HashMap::new())); + let mut cache = cache_lock.lock().unwrap(); + cache.entry(session.id.clone()).or_insert_with(|| CachedCreds { + password: None, + passphrase: None, + kb_responses: None, + }).passphrase = Some(p.clone()); + } + let mut temp_session = session.clone(); + temp_session.passphrase = p; + keypair = load_session_private_key(&temp_session); + } else { + return Err(anyhow!("Passphrase prompt cancelled")); + } + } + } + let keypair = keypair.context("failed to load private key")?; let algorithm = format!("{:?}", keypair.algorithm()); let _ = events.send(BackendEvent::Status { tab_id: tab_id.to_string(), @@ -307,6 +390,83 @@ async fn connect_and_authenticate( } success } + AuthMethod::KeyboardInteractive => { + let cached = { + let cache_lock = CREDENTIALS_CACHE.get_or_init(|| std::sync::Mutex::new(HashMap::new())); + let cache = cache_lock.lock().unwrap(); + cache.get(&session.id).cloned() + }; + let mut response = if cached.as_ref().and_then(|c| c.kb_responses.as_ref()).is_some() { + handle.authenticate_keyboard_interactive_start(&session.user, None).await? + } else { + let prompt_lock = PROMPT_LOCK.get_or_init(|| Mutex::new(())); + let _guard = prompt_lock.lock().await; + handle.authenticate_keyboard_interactive_start(&session.user, None).await? + }; + loop { + match response { + russh::client::KeyboardInteractiveAuthResponse::Success => { + break true; + } + russh::client::KeyboardInteractiveAuthResponse::Failure => { + break false; + } + russh::client::KeyboardInteractiveAuthResponse::InfoRequest { name, instructions, prompts } => { + let mut responses = Vec::new(); + let mut cache_hit = false; + if let Some(c) = cached.as_ref().and_then(|c| c.kb_responses.as_ref()) { + if c.len() == prompts.len() { + responses = c.clone(); + cache_hit = true; + } + } + if !cache_hit { + let prompt_lock = PROMPT_LOCK.get_or_init(|| Mutex::new(())); + let _guard = prompt_lock.lock().await; + let mut prompt_infos = Vec::new(); + for p in &prompts { + prompt_infos.push(PromptInfo { + prompt: p.prompt.clone(), + echo: p.echo, + }); + } + let _ = events.send(BackendEvent::PromptRequest { + tab_id: tab_id.to_string(), + prompt_type: PromptType::KeyboardInteractive, + instruction: format!("{}\n{}", name, instructions), + prompts: prompt_infos, + }); + let mut prompt_res = None; + while let Some(cmd) = commands.recv().await { + match cmd { + BackendCommand::PromptResponse(res) => { + prompt_res = Some(res); + break; + } + BackendCommand::Close => { + return Err(anyhow!("Authentication cancelled")); + } + _ => {} + } + } + if let Some(res) = prompt_res { + responses = res; + let cache_lock = CREDENTIALS_CACHE.get_or_init(|| std::sync::Mutex::new(HashMap::new())); + let mut cache = cache_lock.lock().unwrap(); + cache.entry(session.id.clone()).or_insert_with(|| CachedCreds { + password: None, + passphrase: None, + kb_responses: None, + }).kb_responses = Some(responses.clone()); + } else { + return Err(anyhow!("Keyboard-interactive cancelled")); + } + } + response = handle.authenticate_keyboard_interactive_respond(responses).await?; + } + } + } + } }; if !authed { @@ -328,6 +488,10 @@ async fn connect_and_authenticate( session.port, key_source_label(session) ), + AuthMethod::KeyboardInteractive => format!( + "authentication failed: server rejected keyboard-interactive authentication for {}@{}:{}", + session.user, session.host, session.port + ), } )); } diff --git a/src/session/config.rs b/src/session/config.rs index 08e24f6..f635241 100644 --- a/src/session/config.rs +++ b/src/session/config.rs @@ -6,10 +6,11 @@ use serde::{Deserialize, Serialize}; use uuid::Uuid; #[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] -#[serde(rename_all = "lowercase")] +#[serde(rename_all = "kebab-case")] pub enum AuthMethod { Password, Key, + KeyboardInteractive, } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -73,6 +74,27 @@ impl Session { last_used: None, } } + + pub fn keyboard_interactive( + host: String, + port: u16, + user: String, + ) -> Self { + let name = format!("{user}@{host}"); + Self { + id: Uuid::new_v4().to_string(), + name, + host, + port, + user, + auth: AuthMethod::KeyboardInteractive, + password: String::new(), + private_key_path: String::new(), + private_key_inline: String::new(), + passphrase: String::new(), + last_used: None, + } + } } #[derive(Debug, Clone, Serialize, Deserialize)] diff --git a/src/session/mod.rs b/src/session/mod.rs index 4165425..43b87c4 100644 --- a/src/session/mod.rs +++ b/src/session/mod.rs @@ -100,6 +100,7 @@ impl Ashell { } Session::key(host, port, user, key_path, key_inline, passphrase) } + AuthMethod::KeyboardInteractive => Session::keyboard_interactive(host, port, user), }; session.name = name; if let Some(id) = existing_id { diff --git a/src/sftp/mod.rs b/src/sftp/mod.rs index a331bf5..77d3b5f 100644 --- a/src/sftp/mod.rs +++ b/src/sftp/mod.rs @@ -868,6 +868,34 @@ async fn connect_and_authenticate( } success } + AuthMethod::KeyboardInteractive => { + let cached_kb_responses = { + let cache_lock = crate::backend::ssh::CREDENTIALS_CACHE.get_or_init(|| std::sync::Mutex::new(std::collections::HashMap::new())); + let cache = cache_lock.lock().unwrap(); + cache.get(&session.id).and_then(|c| c.kb_responses.clone()) + }; + let responses = cached_kb_responses.unwrap_or_else(|| vec![session.password.clone()]); + let mut response = handle.authenticate_keyboard_interactive_start(&session.user, None).await?; + loop { + match response { + russh::client::KeyboardInteractiveAuthResponse::Success => { + break true; + } + russh::client::KeyboardInteractiveAuthResponse::Failure => { + break false; + } + russh::client::KeyboardInteractiveAuthResponse::InfoRequest { prompts, .. } => { + let mut resp = responses.clone(); + if resp.len() < prompts.len() { + resp.resize(prompts.len(), String::new()); + } else if resp.len() > prompts.len() { + resp.truncate(prompts.len()); + } + response = handle.authenticate_keyboard_interactive_respond(resp).await?; + } + } + } + } }; if !authed { @@ -879,6 +907,7 @@ async fn connect_and_authenticate( match session.auth { AuthMethod::Password => "password", AuthMethod::Key => "public key", + AuthMethod::KeyboardInteractive => "keyboard interactive", }, session.user, session.host, @@ -892,8 +921,17 @@ async fn connect_and_authenticate( fn load_session_private_key(session: &Session) -> Result { let inline_key = normalize_inline_private_key(&session.private_key_inline); let key_path = expand_key_path(session.private_key_path.trim()); - let passphrase = session.passphrase.trim(); - let passphrase = (!passphrase.is_empty()).then_some(passphrase); + let cached_passphrase = { + let cache_lock = crate::backend::ssh::CREDENTIALS_CACHE.get_or_init(|| std::sync::Mutex::new(std::collections::HashMap::new())); + let cache = cache_lock.lock().unwrap(); + cache.get(&session.id).and_then(|c| c.passphrase.clone()) + }; + let passphrase = if let Some(ref p) = cached_passphrase { + Some(p.as_str()) + } else { + let p = session.passphrase.trim(); + (!p.is_empty()).then_some(p) + }; let has_inline = !inline_key.is_empty(); let has_path = key_path.is_some(); diff --git a/src/terminal/mod.rs b/src/terminal/mod.rs index 1b82893..941156e 100644 --- a/src/terminal/mod.rs +++ b/src/terminal/mod.rs @@ -24,16 +24,35 @@ pub enum TabKind { Ssh, } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum PromptType { + KeyboardInteractive, + Passphrase, +} + +#[derive(Debug, Clone)] +pub struct PromptInfo { + pub prompt: String, + pub echo: bool, +} + #[derive(Debug)] pub enum BackendCommand { Input(Vec), Resize { cols: u16, rows: u16 }, SampleMetrics, Close, + PromptResponse(Vec), } #[derive(Debug, Clone)] pub enum BackendEvent { + PromptRequest { + tab_id: String, + prompt_type: PromptType, + instruction: String, + prompts: Vec, + }, Output { tab_id: String, bytes: Vec,