Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion src/app/remote.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down
12 changes: 9 additions & 3 deletions src/client.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<Duration>) -> Result<Vec<crate::proto::RemoteInstance>, (bool, String)> {
use std::process::Stdio;
Expand Down Expand Up @@ -895,7 +895,12 @@ fn list_remote(ssh: &Path, entry: &crate::config::RemoteEntry, limit: Option<Dur
};
match status.code() {
Some(0) => 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)))
}
}
}

Expand Down Expand Up @@ -2151,14 +2156,15 @@ 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"),
] {
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");
}
}

Expand Down
2 changes: 1 addition & 1 deletion src/proto.rs
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,7 @@ pub enum ClientMsg {
result: Result<Vec<RemoteInstance>, 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,
},
Expand Down
21 changes: 16 additions & 5 deletions src/server_list.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)] };
Expand Down Expand Up @@ -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<String> = i.folders.iter().map(|f| clean(f)).collect();
Expand Down Expand Up @@ -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]
Expand Down
1 change: 1 addition & 0 deletions tests/fixtures/fake-ssh
Original file line number Diff line number Diff line change
Expand Up @@ -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 ;;
Expand Down
Loading