fix(ssh): stop answering "authentication failed" when nothing was tried

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.
This commit is contained in:
l0ng-ai
2026-08-08 02:35:55 +08:00
parent 1f91719da9
commit 4ab1d80ecc
6 changed files with 104 additions and 4 deletions
+66 -3
View File
@@ -33,7 +33,8 @@ pub async fn authenticate(
} => remaining_methods,
};
let mut last_reason = "authentication failed".to_string();
let mut last_reason: Option<String> = 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<MethodKind> {
@@ -742,6 +780,31 @@ fn home_dir() -> Option<String> {
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");
+33 -1
View File
@@ -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()
+1
View File
@@ -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",
+1
View File
@@ -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 => "ローカル",
+2
View File
@@ -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,
+1
View File
@@ -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 => "本地",