mirror of
https://github.com/l0ng-ai/tty7.git
synced 2026-09-22 08:02:24 +00:00
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:
@@ -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
@@ -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()
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 => "ローカル",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 => "本地",
|
||||
|
||||
Reference in New Issue
Block a user