From 4ab1d80ecc403b8cd894fffd8adcdd01685f5dfe Mon Sep 17 00:00:00 2001 From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Date: Sat, 8 Aug 2026 02:35:55 +0800 Subject: [PATCH] fix(ssh): stop answering "authentication failed" when nothing was tried MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The auth loop seeded its reason with "authentication failed" and only replaced it when some method actually reported one. So a round where every method was skipped — no key on disk, no agent, or a connection pinned to a method this server does not offer — came out as a failure, which sends people looking for a wrong password that was never sent. That case now says what it is and names what the server would accept: "no authentication method could be tried; the server offers publickey". The disconnected strip also gained the two things it was missing. It reported only that the connection had ended, so a rejected key and a dropped network read identically and the reason scrolled away with the pane's own output; it now shows the reason in the danger ink. And for a pane spawned from a saved connection it offers "Edit connection…" beside Reconnect — until now the only button was Try Again, on the one error class where trying again unchanged never helps. Tested: unit cover for the no-attempt message (names the offered methods, never says "failed", and does not trail an empty list when the server offered nothing). The strip itself was not seen on screen — this machine has no sshd to fail against, and I did not want to authenticate against someone else's. --- crates/tty7-core/src/daemon/ssh/auth.rs | 69 +++++++++++++++++++++++-- src/ui/forwards.rs | 34 +++++++++++- src/ui/i18n/en.rs | 1 + src/ui/i18n/ja.rs | 1 + src/ui/i18n/mod.rs | 2 + src/ui/i18n/zh.rs | 1 + 6 files changed, 104 insertions(+), 4 deletions(-) diff --git a/crates/tty7-core/src/daemon/ssh/auth.rs b/crates/tty7-core/src/daemon/ssh/auth.rs index 03ee577d..f8661e96 100644 --- a/crates/tty7-core/src/daemon/ssh/auth.rs +++ b/crates/tty7-core/src/daemon/ssh/auth.rs @@ -33,7 +33,8 @@ pub async fn authenticate( } => remaining_methods, }; - let mut last_reason = "authentication failed".to_string(); + let mut last_reason: Option = None; + let mut attempted = false; for family in method_order(spec.auth_mode) { if !remaining.is_empty() && !remaining.contains(&family) { @@ -52,20 +53,57 @@ pub async fn authenticate( remaining_methods, reason, } => { + attempted = true; if let Some(m) = remaining_methods && !m.is_empty() { remaining = m; } if let Some(r) = reason { - last_reason = r; + last_reason = Some(r); } } Outcome::Skipped => {} } } - Err(last_reason) + // "authentication failed" was the answer to two different situations, and + // the more confusing one is that nothing was ever tried: no key on disk, no + // agent, or a connection pinned to a method this server does not offer. + // Saying "failed" there sends people looking for a wrong password. + Err(match (attempted, last_reason) { + (_, Some(reason)) => reason, + (true, None) => "authentication failed".to_string(), + (false, None) => nothing_to_try(spec.auth_mode, &remaining), + }) +} + +/// Every method this connection would have used was either unavailable here or +/// not offered by the server, so the round ended without a single attempt. +fn nothing_to_try(mode: SshAuthMode, remaining: &MethodSet) -> String { + let offered: Vec<&str> = [ + (MethodKind::PublicKey, "publickey"), + (MethodKind::Password, "password"), + (MethodKind::KeyboardInteractive, "keyboard-interactive"), + (MethodKind::GssapiWithMic, "gssapi-with-mic"), + ] + .into_iter() + .filter(|(k, _)| remaining.contains(k)) + .map(|(_, name)| name) + .collect(); + + let wanted = match mode { + SshAuthMode::Auto => "no authentication method could be tried", + SshAuthMode::Gssapi => "gssapi-with-mic could not be tried", + SshAuthMode::PublicKey => "no usable private key was found", + SshAuthMode::Agent => "no agent identity was available", + SshAuthMode::Password => "password auth could not be tried", + SshAuthMode::KeyboardInteractive => "keyboard-interactive could not be tried", + }; + match offered.is_empty() { + true => wanted.to_string(), + false => format!("{wanted}; the server offers {}", offered.join(", ")), + } } fn method_order(mode: SshAuthMode) -> Vec { @@ -742,6 +780,31 @@ fn home_dir() -> Option { mod tests { use super::*; + #[test] + fn a_round_with_no_attempt_says_so_instead_of_saying_it_failed() { + // Nothing on this machine could satisfy the connection, and the server + // says what it would take. "authentication failed" here sends people + // looking for a wrong password that was never sent. + let offers = MethodSet::from(&[MethodKind::PublicKey][..]); + let msg = nothing_to_try(SshAuthMode::Auto, &offers); + assert!( + msg.contains("no authentication method could be tried"), + "{msg}" + ); + assert!(msg.contains("publickey"), "{msg}"); + assert!(!msg.contains("failed"), "{msg}"); + + // A connection pinned to one method names that method. + let msg = nothing_to_try(SshAuthMode::PublicKey, &offers); + assert!(msg.contains("no usable private key"), "{msg}"); + + // A server that offered nothing leaves the sentence without a tail + // rather than with an empty list. + let msg = nothing_to_try(SshAuthMode::Auto, &MethodSet::empty()); + assert!(!msg.contains("offers"), "{msg}"); + assert!(!msg.ends_with(' '), "{msg}"); + } + #[test] fn identity_path_expands_tokens_and_tilde() { let p = expand_identity_path("/keys/%r@%h/id", "example.com", "deploy"); diff --git a/src/ui/forwards.rs b/src/ui/forwards.rs index 45ec7faa..694a6fcf 100644 --- a/src/ui/forwards.rs +++ b/src/ui/forwards.rs @@ -26,6 +26,22 @@ impl Tty7App { .or_else(|| view.remote_context().map(|c| c.target)) .unwrap_or_default(); + // Why it ended. The strip used to say only that it *had* ended, so a + // rejected key and a dropped network read identically — and the reason + // scrolled away with the pane's own output. + let reason = match view.ssh_phase() { + Some(crate::daemon::protocol::SshPhase::Failed { reason }) if !reason.is_empty() => { + Some(reason) + } + _ => None, + }; + // A saved connection is editable; a one-off `user@host` is not, and + // offering to edit one would open an empty page. + let profile = view + .ssh_spec() + .and_then(|s| s.profile_id.clone()) + .and_then(|id| uuid::Uuid::parse_str(&id).ok()); + let theme = cx.theme(); let bar = h_flex() @@ -51,6 +67,13 @@ impl Tty7App { t_fmt(L10nKey::ForwardDisconnectedFrom, &[("host", &host)]) }), ) + .children(reason.map(|reason| { + div() + .max_w(px(360.)) + .truncate() + .text_color(theme.danger) + .child(reason) + })) .child(div().child("· ⌘⇧R")) .child( Button::new("ssh-reconnect") @@ -60,7 +83,16 @@ impl Tty7App { .on_click( cx.listener(|this, _, window, cx| this.restart_ssh_session(window, cx)), ), - ); + ) + .children(profile.map(|id| { + Button::new("ssh-edit-profile") + .label(crate::ui::i18n::t(crate::ui::i18n::L10nKey::SshEditProfile)) + .ghost() + .small() + .on_click(cx.listener(move |this, _, window, cx| { + this.open_ssh_profile_in_settings(id, window, cx) + })) + })); Some( div() .absolute() diff --git a/src/ui/i18n/en.rs b/src/ui/i18n/en.rs index f8767753..30131e41 100644 --- a/src/ui/i18n/en.rs +++ b/src/ui/i18n/en.rs @@ -727,6 +727,7 @@ pub fn translate_en(key: L10nKey) -> &'static str { L10nKey::ForwardPanelTitle => "Forwards", L10nKey::ForwardDisconnected => "Disconnected", L10nKey::ForwardDisconnectedFrom => "Disconnected from {host}", + L10nKey::SshEditProfile => "Edit connection…", L10nKey::ForwardTooltipAdd => "Add forward", L10nKey::ForwardTooltipRemove => "Remove", L10nKey::ForwardLocal => "Local", diff --git a/src/ui/i18n/ja.rs b/src/ui/i18n/ja.rs index 2641042d..7a119ec6 100644 --- a/src/ui/i18n/ja.rs +++ b/src/ui/i18n/ja.rs @@ -771,6 +771,7 @@ pub fn translate_ja(key: L10nKey) -> Option<&'static str> { L10nKey::ForwardPanelTitle => "ポートフォワード", L10nKey::ForwardDisconnected => "切断済み", L10nKey::ForwardDisconnectedFrom => "{host} から切断されました", + L10nKey::SshEditProfile => "接続を編集…", L10nKey::ForwardTooltipAdd => "フォワードを追加", L10nKey::ForwardTooltipRemove => "削除", L10nKey::ForwardLocal => "ローカル", diff --git a/src/ui/i18n/mod.rs b/src/ui/i18n/mod.rs index add96591..7f22ff53 100644 --- a/src/ui/i18n/mod.rs +++ b/src/ui/i18n/mod.rs @@ -551,6 +551,7 @@ pub enum L10nKey { ForwardPanelTitle, ForwardDisconnected, ForwardDisconnectedFrom, + SshEditProfile, ForwardTooltipAdd, ForwardTooltipRemove, ForwardLocal, @@ -1567,6 +1568,7 @@ mod tests { L10nKey::ForwardPanelTitle, L10nKey::ForwardDisconnected, L10nKey::ForwardDisconnectedFrom, + L10nKey::SshEditProfile, L10nKey::ForwardTooltipAdd, L10nKey::ForwardTooltipRemove, L10nKey::ForwardLocal, diff --git a/src/ui/i18n/zh.rs b/src/ui/i18n/zh.rs index cd02070f..f7fb5815 100644 --- a/src/ui/i18n/zh.rs +++ b/src/ui/i18n/zh.rs @@ -702,6 +702,7 @@ pub fn translate_zh(key: L10nKey) -> Option<&'static str> { L10nKey::ForwardPanelTitle => "端口转发", L10nKey::ForwardDisconnected => "已断开", L10nKey::ForwardDisconnectedFrom => "与 {host} 的连接已断开", + L10nKey::SshEditProfile => "编辑连接…", L10nKey::ForwardTooltipAdd => "添加转发", L10nKey::ForwardTooltipRemove => "移除", L10nKey::ForwardLocal => "本地",