diff --git a/Cargo.lock b/Cargo.lock index d5dc378b1..c3491d761 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2165,7 +2165,6 @@ dependencies = [ "regex", "serde", "smol", - "ssh2", "sysinfo", "terminfo", "termwiz", @@ -4399,12 +4398,12 @@ dependencies = [ "rangeset", "ratelim", "smol", - "ssh2", "termwiz", "textwrap 0.13.4", "thiserror", "uds_windows", "url", + "wezterm-ssh", "wezterm-term", ] diff --git a/mux/Cargo.toml b/mux/Cargo.toml index 290b01bca..89391d530 100644 --- a/mux/Cargo.toml +++ b/mux/Cargo.toml @@ -26,7 +26,6 @@ ratelim= { path = "../ratelim" } regex = "1" serde = {version="1.0", features = ["rc", "derive"]} smol = "1.2" -ssh2 = "0.9" terminfo = "0.7" termwiz = { path = "../termwiz" } textwrap = "0.13" diff --git a/mux/src/ssh.rs b/mux/src/ssh.rs index 61b33b1db..283779251 100644 --- a/mux/src/ssh.rs +++ b/mux/src/ssh.rs @@ -11,10 +11,8 @@ use filedescriptor::{socketpair, FileDescriptor}; use portable_pty::cmdbuilder::CommandBuilder; use portable_pty::{ExitStatus, MasterPty, PtySize}; use std::cell::RefCell; -use std::collections::{BTreeMap, HashMap, HashSet, VecDeque}; +use std::collections::{BTreeMap, HashMap, VecDeque}; use std::io::{BufWriter, Read, Write}; -use std::net::TcpStream; -use std::path::Path; use std::rc::Rc; use std::sync::mpsc::{channel, Receiver, Sender, TryRecvError}; use std::time::Duration; @@ -53,187 +51,88 @@ impl LineEditorHost for PasswordPromptHost { } } -impl ssh2::KeyboardInteractivePrompt for ConnectionUI { - fn prompt<'b>( - &mut self, - _username: &str, - instructions: &str, - prompts: &[ssh2::Prompt<'b>], - ) -> Vec { - prompts - .iter() - .map(|p| { - self.output_str(&format!("{}\n", instructions)); - if p.echo { - self.input(&p.text) - } else { - self.password(&p.text) - } - .unwrap_or_else(|_| String::new()) - }) - .collect() - } -} - pub fn ssh_connect_with_ui( remote_address: &str, username: &str, ui: &mut ConnectionUI, -) -> anyhow::Result { +) -> anyhow::Result { let cloned_ui = ui.clone(); cloned_ui.run_and_log_error(move || { - let mut sess = ssh2::Session::new()?; + let mut ssh_config = wezterm_ssh::Config::new(); + ssh_config.add_default_config_files(); - let (remote_address, remote_host_name, port) = { + let (remote_host_name, port) = { let parts: Vec<&str> = remote_address.split(':').collect(); if parts.len() == 2 { - (remote_address.to_string(), parts[0], parts[1].parse()?) + (parts[0], Some(parts[1].parse::()?)) } else { - (format!("{}:22", remote_address), remote_address, 22) + (remote_address, None) } }; + let mut ssh_config = ssh_config.for_host(&remote_host_name); + ssh_config.insert("user".to_string(), username.to_string()); + if let Some(port) = port { + ssh_config.insert("port".to_string(), port.to_string()); + } + ui.output_str(&format!("Connecting to {} using SSH\n", remote_address)); + let (session, events) = Session::connect(ssh_config.clone())?; - let tcp = TcpStream::connect(&remote_address) - .with_context(|| format!("ssh connecting to {}", remote_address))?; - ui.output_str("SSH: Connected OK!\n"); - tcp.set_nodelay(true)?; - sess.set_tcp_stream(tcp); - sess.handshake() - .with_context(|| format!("ssh handshake with {}", remote_address))?; - - if let Ok(mut known_hosts) = sess.known_hosts() { - let varname = if cfg!(windows) { "USERPROFILE" } else { "HOME" }; - let var = std::env::var_os(varname) - .ok_or_else(|| anyhow!("environment variable {} is missing", varname))?; - let file = Path::new(&var).join(".ssh/known_hosts"); - if file.exists() { - known_hosts - .read_file(&file, ssh2::KnownHostFileKind::OpenSSH) - .with_context(|| format!("reading known_hosts file {}", file.display()))?; - } - - let (key, key_type) = sess - .host_key() - .ok_or_else(|| anyhow!("failed to get ssh host key"))?; - - let fingerprint = sess - .host_key_hash(ssh2::HashType::Sha256) - .map(|fingerprint| { - format!( - "SHA256:{}", - base64::encode_config( - fingerprint, - base64::Config::new(base64::CharacterSet::Standard, false) - ) - ) - }) - .or_else(|| { - // Querying for the Sha256 can fail if for example we were linked - // against libssh < 1.9, so let's fall back to Sha1 in that case. - sess.host_key_hash(ssh2::HashType::Sha1).map(|fingerprint| { - let mut res = vec![]; - write!(&mut res, "SHA1").ok(); - for b in fingerprint { - write!(&mut res, ":{:02x}", *b).ok(); - } - String::from_utf8(res).unwrap() - }) - }) - .ok_or_else(|| anyhow!("failed to get host fingerprint"))?; - - use ssh2::CheckResult; - match known_hosts.check_port(&remote_host_name, port, key) { - CheckResult::Match => {} - CheckResult::NotFound => { - ui.output_str(&format!( - "SSH host {} is not yet trusted.\n\ - {:?} Fingerprint: {}.\n\ - Trust and continue connecting?\n", - remote_address, key_type, fingerprint - )); - - loop { - let line = ui.input("Enter [Y/n]> ")?; - + while let Ok(event) = smol::block_on(events.recv()) { + match event { + SessionEvent::Banner(banner) => { + if let Some(banner) = banner { + ui.output_str(&format!("{}\n", banner)); + } + } + SessionEvent::HostVerify(verify) => { + ui.output_str(&format!("{}\n", verify.message)); + let ok = if let Ok(line) = ui.input("Enter [y/n]> ") { match line.as_ref() { - "y" | "Y" | "yes" | "YES" => break, - "n" | "N" | "no" | "NO" => bail!("user declined to trust host"), - _ => continue, + "y" | "Y" | "yes" | "YES" => true, + "n" | "N" | "no" | "NO" | _ => false, + } + } else { + false + }; + smol::block_on(verify.answer(ok)).context("send verify response")?; + } + SessionEvent::Authenticate(auth) => { + if !auth.username.is_empty() { + ui.output_str(&format!("Authentication for {}\n", auth.username)); + } + if !auth.instructions.is_empty() { + ui.output_str(&format!("{}\n", auth.instructions)); + } + let mut answers = vec![]; + for prompt in &auth.prompts { + let mut prompt_lines = prompt.prompt.split('\n').collect::>(); + let editor_prompt = prompt_lines.pop().unwrap(); + for line in &prompt_lines { + ui.output_str(&format!("{}\n", line)); + } + let res = if prompt.echo { + ui.input(editor_prompt) + } else { + ui.password(editor_prompt) + }; + if let Ok(line) = res { + answers.push(line); + } else { + anyhow::bail!("Authentication was cancelled"); } } - - known_hosts - .add(remote_host_name, key, &remote_address, key_type.into()) - .context("adding known_hosts entry in memory")?; - - known_hosts - .write_file(&file, ssh2::KnownHostFileKind::OpenSSH) - .with_context(|| format!("writing known_hosts file {}", file.display()))?; + smol::block_on(auth.answer(answers))?; } - CheckResult::Mismatch => { - ui.output_str(&format!( - "🛑 host key mismatch for ssh server {}.\n\ - Got fingerprint {} instead of expected value from known_hosts\n\ - file {}.\n\ - Refusing to connect.\n", - remote_address, - fingerprint, - file.display() - )); - bail!("host mismatch, man in the middle attack?!"); - } - CheckResult::Failure => { - ui.output_str("🛑 Failed to load and check known ssh hosts\n"); - bail!("failed to check the known hosts"); + SessionEvent::Error(err) => { + anyhow::bail!("Error: {}", err); } + SessionEvent::Authenticated => return Ok(session), } } - - for _ in 0..3 { - if sess.authenticated() { - break; - } - - // Re-query the auth methods on each loop as a successful method - // may unlock a new method on a subsequent iteration (eg: password - // auth may then unlock 2fac) - let methods: HashSet<&str> = sess.auth_methods(&username)?.split(',').collect(); - log::trace!("ssh auth methods: {:?}", methods); - - if !sess.authenticated() && methods.contains("publickey") { - if let Err(err) = sess.userauth_agent(&username) { - log::warn!("while attempting agent auth: {}", err); - } else if sess.authenticated() { - ui.output_str("publickey auth successful!\n"); - } - } - - if !sess.authenticated() && methods.contains("password") { - ui.output_str(&format!( - "Password authentication for {}@{}\n", - username, remote_address - )); - let pass = ui.password("🔐 Password: ")?; - if let Err(err) = sess.userauth_password(username, &pass) { - log::error!("while attempting password auth: {}", err); - } - } - - if !sess.authenticated() && methods.contains("keyboard-interactive") { - if let Err(err) = sess.userauth_keyboard_interactive(&username, ui) { - log::error!("while attempting keyboard-interactive auth: {}", err); - } - } - } - - if !sess.authenticated() { - bail!("unable to authenticate session"); - } - - Ok(sess) + bail!("unable to authenticate session"); }) } diff --git a/wezterm-client/Cargo.toml b/wezterm-client/Cargo.toml index f99882e2c..a2f284f10 100644 --- a/wezterm-client/Cargo.toml +++ b/wezterm-client/Cargo.toml @@ -19,16 +19,16 @@ lru = "0.6" metrics = { version="0.14", features=["std"]} mux = { path = "../mux" } openssl = "0.10" -portable-pty = { path = "../pty", features = ["serde_support", "ssh"]} +portable-pty = { path = "../pty", features = ["serde_support"]} promise = { path = "../promise" } rangeset = { path = "../rangeset" } ratelim= { path = "../ratelim" } smol = "1.2" -ssh2 = "0.9" termwiz = { path = "../termwiz" } textwrap = "0.13" thiserror = "1.0" url = "2" +wezterm-ssh = { path = "../wezterm-ssh" } wezterm-term = { path = "../term", features=["use_serde"] } [target."cfg(windows)".dependencies] diff --git a/wezterm-client/src/client.rs b/wezterm-client/src/client.rs index 93fe395b4..b0d5d786a 100644 --- a/wezterm-client/src/client.rs +++ b/wezterm-client/src/client.rs @@ -6,6 +6,7 @@ use async_ossl::AsyncSslStream; use async_trait::async_trait; use codec::*; use config::{configuration, SshDomain, TlsDomainClient, UnixDomain}; +use filedescriptor::FileDescriptor; use futures::FutureExt; use mux::connui::ConnectionUI; use mux::domain::{alloc_domain_id, DomainId}; @@ -18,7 +19,6 @@ use smol::channel::{bounded, unbounded, Receiver, Sender}; use smol::prelude::*; use smol::{block_on, Async}; use std::collections::HashMap; -use std::convert::TryInto; use std::io::{Read, Write}; use std::marker::Unpin; use std::net::TcpStream; @@ -312,8 +312,9 @@ struct Reconnectable { } struct SshStream { - chan: ssh2::Channel, - sess: ssh2::Session, + stdin: FileDescriptor, + stdout: FileDescriptor, + _child: wezterm_ssh::SshChildProcess, } impl std::fmt::Debug for SshStream { @@ -325,60 +326,29 @@ impl std::fmt::Debug for SshStream { #[cfg(unix)] impl std::os::unix::io::AsRawFd for SshStream { fn as_raw_fd(&self) -> std::os::unix::io::RawFd { - self.sess.as_raw_fd() + self.stdout.as_raw_fd() } } #[cfg(windows)] impl std::os::windows::io::AsRawSocket for SshStream { fn as_raw_socket(&self) -> std::os::windows::io::RawSocket { - self.sess.as_raw_socket() - } -} - -impl SshStream { - fn process_stderr(&mut self) { - let blocking = self.sess.is_blocking(); - self.sess.set_blocking(false); - - loop { - let mut buf = [0u8; 1024]; - match self.chan.stderr().read(&mut buf) { - Ok(size) => { - if size == 0 { - break; - } else { - let stderr = &buf[0..size]; - log::error!("ssh stderr: {}", String::from_utf8_lossy(stderr)); - } - } - Err(e) => { - if e.kind() != std::io::ErrorKind::WouldBlock { - log::error!("ssh error reading stderr: {}", e); - } - break; - } - } - } - - self.sess.set_blocking(blocking); + self.stdout.as_raw_socket() } } impl Read for SshStream { fn read(&mut self, buf: &mut [u8]) -> Result { - // Take the opportunity to read and show data from stderr - self.process_stderr(); - self.chan.read(buf) + self.stdout.read(buf) } } impl Write for SshStream { fn write(&mut self, buf: &[u8]) -> Result { - self.chan.write(buf) + self.stdin.write(buf) } fn flush(&mut self) -> Result<(), std::io::Error> { - self.chan.flush() + self.stdin.flush() } } @@ -456,10 +426,6 @@ impl Reconnectable { ui: &mut ConnectionUI, ) -> anyhow::Result<()> { let sess = ssh_connect_with_ui(&ssh_dom.remote_address, &ssh_dom.username, ui)?; - sess.set_timeout(ssh_dom.timeout.as_secs().try_into()?); - - let mut chan = sess.channel_session()?; - let proxy_bin = Self::wezterm_bin_path(&ssh_dom.remote_wezterm_path); let cmd = if initial { @@ -469,9 +435,27 @@ impl Reconnectable { }; ui.output_str(&format!("Running: {}\n", cmd)); log::error!("going to run {}", cmd); - chan.exec(&cmd)?; - let stream: Box = Box::new(Async::new(SshStream { sess, chan })?); + let exec = smol::block_on(sess.exec(&cmd, None))?; + + let mut stderr = exec.stderr; + std::thread::spawn(move || { + let mut buf = [0u8; 1024]; + while let Ok(len) = stderr.read(&mut buf) { + if len == 0 { + break; + } else { + let stderr = &buf[0..len]; + log::error!("ssh stderr: {}", String::from_utf8_lossy(stderr)); + } + } + }); + + let stream: Box = Box::new(Async::new(SshStream { + stdin: exec.stdin, + stdout: exec.stdout, + _child: exec.child, + })?); self.stream.replace(stream); Ok(()) } @@ -593,8 +577,6 @@ impl Reconnectable { ssh_connect_with_ui(&ssh_params.host_and_port, &ssh_params.username, ui)?; let creds = ui.run_and_log_error(|| { - let mut chan = sess.channel_session()?; - // The `tlscreds` command will start the server if needed and then // obtain client credentials that we can use for tls. let cmd = format!( @@ -603,20 +585,20 @@ impl Reconnectable { ); ui.output_str(&format!("Running: {}\n", cmd)); - chan.exec(&cmd) + let mut exec = smol::block_on(sess.exec(&cmd, None)) .with_context(|| format!("executing `{}` on remote host", cmd))?; // stdout holds an encoded pdu let mut buf = Vec::new(); - chan.read_to_end(&mut buf) + exec.stdout + .read_to_end(&mut buf) .context("reading tlscreds response to buffer")?; - chan.send_eof()?; - chan.wait_eof()?; + drop(exec.stdin); // stderr is ideally empty let mut err = String::new(); - chan.stderr() + exec.stderr .read_to_string(&mut err) .context("reading tlscreds stderr")?; if !err.is_empty() { @@ -624,11 +606,11 @@ impl Reconnectable { } let creds = match Pdu::decode(buf.as_slice()) - .with_context(|| format!("reading tlscreds response. stderr={}", err))? + .context("reading tlscreds response")? .pdu { Pdu::GetTlsCredsResponse(creds) => creds, - _ => bail!("unexpected response to tlscreds, stderr={}", err), + _ => bail!("unexpected response to tlscreds"), }; // Save the credentials to disk, as that is currently the easiest diff --git a/wezterm-ssh/src/session.rs b/wezterm-ssh/src/session.rs index d92900b4b..6e113025d 100644 --- a/wezterm-ssh/src/session.rs +++ b/wezterm-ssh/src/session.rs @@ -53,6 +53,14 @@ impl SessionSender { pub(crate) enum SessionRequest { NewPty(NewPty), ResizePty(ResizePty), + Exec(Exec), +} + +#[derive(Debug)] +pub(crate) struct Exec { + pub command_line: String, + pub env: Option>, + pub reply: Sender, } pub(crate) struct DescriptorState { @@ -328,12 +336,89 @@ impl SessionInner { } Ok(true) } + SessionRequest::Exec(exec) => { + if let Err(err) = self.exec(&sess, &exec) { + log::error!("{:?} -> error: {:#}", exec, err); + } + Ok(true) + } }; sess.set_blocking(false); res } } } + + pub fn exec(&mut self, sess: &ssh2::Session, exec: &Exec) -> anyhow::Result<()> { + sess.set_blocking(true); + + let mut channel = sess.channel_session()?; + + if let Some(env) = &exec.env { + for (key, val) in env { + if let Err(err) = channel.setenv(key, val) { + // Depending on the server configuration, a given + // setenv request may not succeed, but that doesn't + // prevent the connection from being set up. + log::warn!("ssh: setenv {}={} failed: {}", key, val, err); + } + } + } + + channel.exec(&exec.command_line)?; + + let channel_id = self.next_channel_id; + self.next_channel_id += 1; + + let (write_to_stdin, mut read_from_stdin) = socketpair()?; + let (mut write_to_stdout, read_from_stdout) = socketpair()?; + let (mut write_to_stderr, read_from_stderr) = socketpair()?; + + read_from_stdin.set_non_blocking(true)?; + write_to_stdout.set_non_blocking(true)?; + write_to_stderr.set_non_blocking(true)?; + + let (exit_tx, exit_rx) = bounded(1); + + let child = SshChildProcess { + channel: channel_id, + tx: None, + exit: exit_rx, + exited: None, + }; + + let result = ExecResult { + stdin: write_to_stdin, + stdout: read_from_stdout, + stderr: read_from_stderr, + child, + }; + + let info = ChannelInfo { + channel_id, + channel, + exit: Some(exit_tx), + descriptors: [ + DescriptorState { + fd: Some(read_from_stdin), + buf: VecDeque::with_capacity(8192), + }, + DescriptorState { + fd: Some(write_to_stdout), + buf: VecDeque::with_capacity(8192), + }, + DescriptorState { + fd: Some(write_to_stderr), + buf: VecDeque::with_capacity(8192), + }, + ], + }; + + exec.reply.try_send(result)?; + self.channels.insert(channel_id, info); + + Ok(()) + } } #[derive(Clone)] @@ -394,6 +479,32 @@ impl Session { child.tx.replace(self.tx.clone()); Ok((ssh_pty, child)) } + + pub async fn exec( + &self, + command_line: &str, + env: Option>, + ) -> anyhow::Result { + let (reply, rx) = bounded(1); + self.tx + .send(SessionRequest::Exec(Exec { + command_line: command_line.to_string(), + env, + reply, + })) + .await?; + let mut exec = rx.recv().await?; + exec.child.tx.replace(self.tx.clone()); + Ok(exec) + } +} + +#[derive(Debug)] +pub struct ExecResult { + pub stdin: FileDescriptor, + pub stdout: FileDescriptor, + pub stderr: FileDescriptor, + pub child: SshChildProcess, } fn write_from_buf(w: &mut W, buf: &mut VecDeque) -> std::io::Result<()> {