diff --git a/src/app/remote.rs b/src/app/remote.rs index 1b8869d..014ae28 100644 --- a/src/app/remote.rs +++ b/src/app/remote.rs @@ -179,7 +179,7 @@ impl App { if let Some(w) = self.servers_window() { w.listed(&name, Machine::Failed(e.clone())); } - // Only ssh itself failing can be a prompt BatchMode refused; the rest are answers. + // Only a prompt ssh refused is answered by a terminal; the rest show in the machine's section. if !ssh_failed { return; } diff --git a/src/client.rs b/src/client.rs index 0426344..f6f038b 100644 --- a/src/client.rs +++ b/src/client.rs @@ -849,7 +849,7 @@ fn last_line(stderr: &str) -> String { stderr.lines().map(str::trim).rfind(|l| !l.is_empty()).map(printable).unwrap_or_default() } -/// The instances on a machine, through `nerd --list --json` there; the error says whether ssh itself failed. +/// The instances on a machine, through `nerd --list --json` there; the error says whether ssh refused a prompt a terminal can answer. /// With a limit (the menu) ssh runs in BatchMode and is killed when it runs out; without one (a terminal) a human waits. fn list_remote(ssh: &Path, entry: &crate::config::RemoteEntry, limit: Option) -> Result, (bool, String)> { use std::process::Stdio; @@ -895,7 +895,12 @@ fn list_remote(ssh: &Path, entry: &crate::config::RemoteEntry, limit: Option parse_listing(&stdout, &entry.name).map_err(|e| (false, e)), - code => Err((code == Some(255), ssh_failure(&entry.name, code, &String::from_utf8_lossy(&stderr)))), + code => { + let said = String::from_utf8_lossy(&stderr); + // what ssh says when BatchMode refused a password or a host key, which a terminal can answer + let prompt = code == Some(255) && ["Permission denied (", "Host key verification failed"].iter().any(|p| said.contains(p)); + Err((prompt, ssh_failure(&entry.name, code, &said))) + } } } @@ -2151,6 +2156,7 @@ mod remote_tests { for (mode, want) in [ ("refused", "ssh to studio failed: ssh: connect to host fake-host port 22: Connection refused"), + ("denied", "ssh to studio failed: fake-host: Permission denied (publickey,password)."), ("timeout", "ssh to studio timed out"), ("nonerd", "studio has no nerd (looked in PATH, ~/.local/bin, /opt/homebrew/bin, /usr/local/bin)"), ("garbage", "nerd on studio is older than this one, or its shell startup prints text: Last login: yesterday"), @@ -2158,7 +2164,7 @@ mod remote_tests { std::fs::write(fake.dir.join("mode"), mode).unwrap(); let (ssh_failed, said) = list_remote(&fake.ssh(), &fake.entry(), None).unwrap_err(); assert_eq!(said, want, "{mode}"); - assert_eq!(ssh_failed, matches!(mode, "refused" | "timeout"), "{mode}: only ssh's own exit offers a terminal"); + assert_eq!(ssh_failed, mode == "denied", "{mode}: only a prompt ssh refused offers a terminal"); } } diff --git a/src/proto.rs b/src/proto.rs index 116df9f..ef4034f 100644 --- a/src/proto.rs +++ b/src/proto.rs @@ -82,7 +82,7 @@ pub enum ClientMsg { result: Result, String>, #[serde(default)] window: bool, - /// ssh itself failed (exit 255): perhaps a prompt BatchMode refused, which a terminal can answer. + /// ssh refused a password or host key prompt under BatchMode, which a terminal can answer. #[serde(default)] ssh_failed: bool, }, diff --git a/src/server_list.rs b/src/server_list.rs index b0bd86a..beeee73 100644 --- a/src/server_list.rs +++ b/src/server_list.rs @@ -208,8 +208,10 @@ impl ServerList { CONFIG => return vec![('e', "add a computer in config.json…", Act::EditConfig)], _ => {} } - if id.starts_with("m:") { - return vec![('\n', open(self.view.open.contains(id)), Act::Toggle)]; + if let Some(name) = id.strip_prefix("m:") { + // a machine nerd will not ssh to has nothing to list + let usable = self.machines.iter().any(|(r, _)| r.name == name && r.check().is_ok()); + return if usable { vec![('\n', open(self.view.open.contains(id)), Act::Toggle)] } else { Vec::new() }; } if let Some(pid) = id.strip_prefix("p:") { return if self.stray(pid).is_some_and(|s| s.other_home) { Vec::new() } else { vec![('s', "stop…", Act::Stop)] }; @@ -409,9 +411,14 @@ impl ServerList { _ => {} } if let Some(name) = id.strip_prefix("m:") - && let Some((r, _)) = self.machines.iter().find(|(r, _)| r.name == name) + && let Some((r, m)) = self.machines.iter().find(|(r, _)| r.name == name) { - out.push(format!("{} at {}: its servers are listed over ssh when you open it, and one opens in a terminal window of its own.", clean(&r.name), clean(&r.host))); + out.push(match (r.check(), m) { + // the row under the machine is cut to the window's width; here it is whole + (Ok(()), Machine::Failed(e)) => clean(e), + (Ok(()), _) => format!("{} at {}: its servers are listed over ssh when you open it, and one opens in a terminal window of its own.", clean(&r.name), clean(&r.host)), + (Err(e), _) => format!("{} cannot be used: {e}. Fix it in \"remotes\" in config.json (nerd --config prints its path).", clean(&r.name)), + }); } if let Some((r, i, _)) = self.remote(id) { let folders: Vec = i.folders.iter().map(|f| clean(f)).collect(); @@ -636,9 +643,13 @@ pub(crate) mod tests { assert!(!ids(&w).contains(&"r:studio:7".to_string())); w.listed("studio", Machine::Failed("late".into())); assert!(matches!(w.machines[0].1, Machine::Listed(..)), "an answer for a closed machine changes nothing"); + w.act("m:studio", 0); + w.listed("studio", Machine::Failed("ssh to studio failed: ssh: Could not resolve hostname studio.local".into())); + assert_eq!(w.details("m:studio"), ["ssh to studio failed: ssh: Could not resolve hostname studio.local"], "the whole failure, under the machine"); let mut bad = ServerList::new("1".into(), vec![RemoteEntry { name: "x".into(), host: "-oProxyCommand=x".into() }], true, false); + assert!(offered(&bad, "m:x").is_empty(), "nothing to list on a bad entry"); assert!(matches!(bad.act("m:x", 0), Out::None), "a bad entry never reaches the client"); - assert!(matches!(&bad.machines[0].1, Machine::Failed(e) if e.starts_with("not usable"))); + assert_eq!(bad.details("m:x"), ["x cannot be used: a host cannot start with '-'. Fix it in \"remotes\" in config.json (nerd --config prints its path)."]); } #[test] diff --git a/tests/fixtures/fake-ssh b/tests/fixtures/fake-ssh index 24975c1..f649608 100755 --- a/tests/fixtures/fake-ssh +++ b/tests/fixtures/fake-ssh @@ -8,6 +8,7 @@ case $last in *"nerd --list --json") case $mode in refused) echo "ssh: connect to host fake-host port 22: Connection refused" >&2; exit 255 ;; + denied) echo "fake-host: Permission denied (publickey,password)." >&2; exit 255 ;; timeout) echo "ssh: connect to host fake-host port 22: Operation timed out" >&2; exit 255 ;; nonerd) echo "zsh:1: command not found: nerd" >&2; exit 127 ;; garbage) printf 'Last login: yesterday\nnerd 0.5.0\n'; exit 0 ;;