1
1
mirror of https://github.com/wez/wezterm.git synced 2024-12-23 21:32:13 +03:00

ssh: use a single window for authenticating a session

This makes it so that we preserve context while showing the connection
status and authentication prompts.
This commit is contained in:
Wez Furlong 2020-01-25 13:00:16 -08:00
parent 1ef95b917a
commit fd8f28960f
8 changed files with 256 additions and 139 deletions

16
Cargo.lock generated
View File

@ -539,6 +539,20 @@ dependencies = [
"cfg-if",
]
[[package]]
name = "crossbeam"
version = "0.7.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "69323bff1fb41c635347b8ead484a5ca6c3f11914d784170b158d8449ab07f8e"
dependencies = [
"cfg-if",
"crossbeam-channel 0.4.0",
"crossbeam-deque",
"crossbeam-epoch",
"crossbeam-queue",
"crossbeam-utils 0.7.0",
]
[[package]]
name = "crossbeam-channel"
version = "0.3.9"
@ -3401,7 +3415,7 @@ dependencies = [
"core-foundation 0.7.0",
"core-graphics 0.19.0",
"core-text 15.0.0",
"crossbeam-channel 0.3.9",
"crossbeam",
"daemonize",
"dirs 1.0.5",
"downcast-rs",

View File

@ -21,7 +21,7 @@ base64 = "0.10"
base91 = { path = "base91" }
rangeset = { path = "rangeset" }
bitflags = "1.0"
crossbeam-channel = "0.3"
crossbeam = "0.7"
dirs = "1.0"
downcast-rs = "1.0"
euclid = "0.20"

View File

@ -6,7 +6,7 @@ use crate::mux::window::WindowId;
use crate::mux::Mux;
use crate::server::listener::spawn_listener;
use anyhow::{bail, Error};
use crossbeam_channel::{unbounded as channel, Receiver};
use crossbeam::channel::{unbounded as channel, Receiver};
use log::info;
use promise::*;
use std::rc::Rc;

View File

@ -9,7 +9,7 @@ use crate::server::tab::ClientTab;
use crate::server::UnixStream;
use crate::ssh::ssh_connect;
use anyhow::{anyhow, bail, Context, Error};
use crossbeam_channel::TryRecvError;
use crossbeam::channel::TryRecvError;
use filedescriptor::{pollfd, AsRawSocketDescriptor};
use log::info;
use portable_pty::{CommandBuilder, NativePtySystem, PtySystem};

View File

@ -4,7 +4,7 @@ use crate::mux::{Mux, MuxNotification, MuxSubscriber};
use crate::server::codec::*;
use crate::server::pollable::*;
use anyhow::{anyhow, bail, Context, Error};
use crossbeam_channel::TryRecvError;
use crossbeam::channel::TryRecvError;
use log::error;
use portable_pty::PtySize;
use promise::spawn::spawn_into_main_thread;

View File

@ -1,6 +1,6 @@
use crate::server::UnixStream;
use anyhow::Error;
use crossbeam_channel::{unbounded as channel, Receiver, Sender, TryRecvError};
use crossbeam::channel::{unbounded as channel, Receiver, Sender, TryRecvError};
use filedescriptor::*;
use std::cell::RefCell;
use std::io::{Read, Write};

View File

@ -6,6 +6,7 @@ use crate::mux::Mux;
use crate::termwiztermtab;
use anyhow::{anyhow, bail, Context, Error};
use async_trait::async_trait;
use crossbeam::channel::{bounded, Receiver, Sender};
use portable_pty::cmdbuilder::CommandBuilder;
use portable_pty::{PtySize, PtySystem};
use promise::{Future, Promise};
@ -14,96 +15,99 @@ use std::io::Write;
use std::net::TcpStream;
use std::path::Path;
use std::rc::Rc;
use termwiz::cell::{unicode_column_width, AttributeChange, Intensity};
use std::time::Duration;
use termwiz::cell::unicode_column_width;
use termwiz::lineedit::*;
use termwiz::surface::Change;
use termwiz::terminal::*;
fn password_prompt(
instructions: &str,
prompt: &str,
username: &str,
remote_address: &str,
) -> Option<String> {
let title = "🔐 wezterm: SSH authentication".to_string();
let text = format!(
"🔐 SSH Authentication for {} @ {}\n{}\n",
username, remote_address, instructions
)
.replace("\n", "\r\n");
let prompt = prompt.to_string();
#[derive(Default)]
struct PasswordPromptHost {
history: BasicHistory,
#[derive(Default)]
struct PasswordPromptHost {
history: BasicHistory,
}
impl LineEditorHost for PasswordPromptHost {
fn history(&mut self) -> &mut dyn History {
&mut self.history
}
impl LineEditorHost for PasswordPromptHost {
fn history(&mut self) -> &mut dyn History {
&mut self.history
}
// Rewrite the input so that we can obscure the password
// characters when output to the terminal widget
fn highlight_line(
&self,
line: &str,
cursor_position: usize,
) -> (Vec<OutputElement>, usize) {
let placeholder = "🔑";
let grapheme_count = unicode_column_width(line);
let mut output = vec![];
for _ in 0..grapheme_count {
output.push(OutputElement::Text(placeholder.to_string()));
// Rewrite the input so that we can obscure the password
// characters when output to the terminal widget
fn highlight_line(&self, line: &str, cursor_position: usize) -> (Vec<OutputElement>, usize) {
let placeholder = "🔑";
let grapheme_count = unicode_column_width(line);
let mut output = vec![];
for _ in 0..grapheme_count {
output.push(OutputElement::Text(placeholder.to_string()));
}
(output, unicode_column_width(placeholder) * cursor_position)
}
}
enum UIRequest {
/// Display something
Output(Vec<Change>),
/// Request input
Input {
prompt: String,
echo: bool,
respond: Promise<String>,
},
Close,
}
struct SshUIImpl {
term: termwiztermtab::TermWizTerminal,
rx: Receiver<UIRequest>,
}
impl SshUIImpl {
fn run(&mut self) -> anyhow::Result<()> {
let title = "🔐 wezterm: SSH authentication".to_string();
self.term.render(&[Change::Title(title)])?;
loop {
match self.rx.recv_timeout(Duration::from_millis(200)) {
Ok(UIRequest::Close) => break,
Ok(UIRequest::Output(changes)) => self.term.render(&changes)?,
Ok(UIRequest::Input {
prompt,
echo: true,
mut respond,
}) => {
respond.result(self.input_prompt(&prompt));
}
Ok(UIRequest::Input {
prompt,
echo: false,
mut respond,
}) => {
respond.result(self.password_prompt(&prompt));
}
Err(err) if err.is_timeout() => {}
Err(err) => bail!("recv_timeout: {}", err),
}
(output, unicode_column_width(placeholder) * cursor_position)
}
}
match promise::spawn::block_on(termwiztermtab::run(60, 10, move |mut term| {
term.render(&[
// Change::Attribute(AttributeChange::Intensity(Intensity::Bold)),
Change::Title(title.to_string()),
Change::Text(text.to_string()),
Change::Attribute(AttributeChange::Intensity(Intensity::Normal)),
])?;
let mut editor = LineEditor::new(term);
editor.set_prompt(&format!("{}: ", prompt));
std::thread::sleep(Duration::new(2, 0));
Ok(())
}
fn password_prompt(&mut self, prompt: &str) -> anyhow::Result<String> {
let mut editor = LineEditor::new(&mut self.term);
editor.set_prompt(prompt);
let mut host = PasswordPromptHost::default();
if let Some(line) = editor.read_line(&mut host)? {
Ok(line)
} else {
bail!("prompt cancelled");
}
})) {
Ok(p) => Some(p),
Err(p) => {
log::error!("failed to prompt for pw: {}", p);
None
bail!("password entry was cancelled");
}
}
}
fn input_prompt(
instructions: &str,
prompt: &str,
username: &str,
remote_address: &str,
) -> Option<String> {
let title = "🔐 wezterm: SSH authentication".to_string();
let text = format!(
"SSH Authentication for {} @ {}\n{}\n{}\n",
username, remote_address, instructions, prompt
)
.replace("\n", "\r\n");
match promise::spawn::block_on(termwiztermtab::run(60, 10, move |mut term| {
term.render(&[
Change::Title(title.to_string()),
Change::Text(text.to_string()),
Change::Attribute(AttributeChange::Intensity(Intensity::Normal)),
])?;
let mut editor = LineEditor::new(term);
fn input_prompt(&mut self, prompt: &str) -> anyhow::Result<String> {
let mut editor = LineEditor::new(&mut self.term);
editor.set_prompt(prompt);
let mut host = NopLineEditorHost::default();
if let Some(line) = editor.read_line(&mut host)? {
@ -111,21 +115,72 @@ fn input_prompt(
} else {
bail!("prompt cancelled");
}
})) {
Ok(p) => Some(p),
Err(p) => {
log::error!("failed to prompt for pw: {}", p);
None
}
}
}
struct Prompt<'a> {
username: &'a str,
remote_address: &'a str,
struct SshUI {
tx: Sender<UIRequest>,
}
impl<'a> ssh2::KeyboardInteractivePrompt for Prompt<'a> {
impl SshUI {
fn new() -> Self {
let (tx, rx) = bounded(16);
promise::spawn::spawn_into_main_thread(termwiztermtab::run(70, 15, move |term| {
let mut ui = SshUIImpl { term, rx };
ui.run()
}));
Self { tx }
}
fn output(&self, changes: Vec<Change>) {
self.tx
.send(UIRequest::Output(changes))
.expect("send to SShUI failed");
}
fn output_str(&self, s: &str) {
let s = s.replace("\n", "\r\n");
self.output(vec![Change::Text(s)]);
}
fn input(&self, prompt: &str) -> anyhow::Result<String> {
let mut promise = Promise::new();
let future = promise.get_future().unwrap();
self.tx
.send(UIRequest::Input {
prompt: prompt.replace("\n", "\r\n"),
echo: true,
respond: promise,
})
.expect("send to SshUI failed");
future.wait()
}
fn password(&self, prompt: &str) -> anyhow::Result<String> {
let mut promise = Promise::new();
let future = promise.get_future().unwrap();
self.tx
.send(UIRequest::Input {
prompt: prompt.replace("\n", "\r\n"),
echo: false,
respond: promise,
})
.expect("send to SshUI failed");
future.wait()
}
fn close(&self) {
self.tx
.send(UIRequest::Close)
.expect("send to SshUI failed");
}
}
impl ssh2::KeyboardInteractivePrompt for SshUI {
fn prompt<'b>(
&mut self,
_username: &str,
@ -135,14 +190,13 @@ impl<'a> ssh2::KeyboardInteractivePrompt for Prompt<'a> {
prompts
.iter()
.map(|p| {
let func = if p.echo {
input_prompt
self.output_str(&format!("{}\n", instructions));
if p.echo {
self.input(&p.text)
} else {
password_prompt
};
func(instructions, &p.text, &self.username, &self.remote_address)
.unwrap_or_else(String::new)
self.password(&p.text)
}
.unwrap_or_else(|_| String::new())
})
.collect()
}
@ -157,7 +211,11 @@ pub fn async_ssh_connect(remote_address: &str, username: &str) -> Future<ssh2::S
future
}
pub fn ssh_connect(remote_address: &str, username: &str) -> anyhow::Result<ssh2::Session> {
fn ssh_connect_with_ui(
remote_address: &str,
username: &str,
ui: &mut SshUI,
) -> anyhow::Result<ssh2::Session> {
let mut sess = ssh2::Session::new()?;
let (remote_address, remote_host_name, port) = {
@ -170,8 +228,11 @@ pub fn ssh_connect(remote_address: &str, username: &str) -> anyhow::Result<ssh2:
}
};
ui.output_str(&format!("Connecting to {}\n", remote_address));
let tcp = TcpStream::connect(&remote_address)
.with_context(|| format!("ssh connecting to {}", remote_address))?;
ui.output_str("Connected OK!\n");
tcp.set_nodelay(true)?;
sess.set_tcp_stream(tcp);
sess.handshake()
@ -221,37 +282,21 @@ pub fn ssh_connect(remote_address: &str, username: &str) -> anyhow::Result<ssh2:
match known_hosts.check_port(&remote_host_name, port, key) {
CheckResult::Match => {}
CheckResult::NotFound => {
let message = format!(
"SSH host {} is not yet trusted.\r\n\
{:?} Fingerprint: {}.\r\n\
Trust and continue connecting?\r\n",
ui.output_str(&format!(
"SSH host {} is not yet trusted.\n\
{:?} Fingerprint: {}.\n\
Trust and continue connecting?\n",
remote_address, key_type, fingerprint
);
));
let allow =
promise::spawn::block_on(termwiztermtab::run(80, 10, move |mut term| {
let title = "🔐 wezterm: SSH authentication".to_string();
term.render(&[Change::Title(title), Change::Text(message.to_string())])?;
loop {
let line = ui.input("Enter [Y/n]> ")?;
let mut editor = LineEditor::new(term);
editor.set_prompt("Enter [Y/n]> ");
let mut host = NopLineEditorHost::default();
loop {
let line = match editor.read_line(&mut host) {
Ok(Some(line)) => line,
Ok(None) | Err(_) => return Ok(false),
};
match line.as_ref() {
"y" | "Y" | "yes" | "YES" => return Ok(true),
"n" | "N" | "no" | "NO" => return Ok(false),
_ => continue,
}
}
}))?;
if !allow {
bail!("user declined to trust host");
match line.as_ref() {
"y" | "Y" | "yes" | "YES" => break,
"n" | "N" | "no" | "NO" => bail!("user declined to trust host"),
_ => continue,
}
}
known_hosts
@ -263,11 +308,11 @@ pub fn ssh_connect(remote_address: &str, username: &str) -> anyhow::Result<ssh2:
.with_context(|| format!("writing known_hosts file {}", file.display()))?;
}
CheckResult::Mismatch => {
termwiztermtab::message_box_ok(&format!(
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.",
Refusing to connect.\n",
remote_address,
fingerprint,
file.display()
@ -275,7 +320,7 @@ pub fn ssh_connect(remote_address: &str, username: &str) -> anyhow::Result<ssh2:
bail!("host mismatch, man in the middle attack?!");
}
CheckResult::Failure => {
termwiztermtab::message_box_ok("🛑 Failed to load and check known ssh hosts");
ui.output_str("🛑 Failed to load and check known ssh hosts\n");
bail!("failed to check the known hosts");
}
}
@ -295,24 +340,24 @@ pub fn ssh_connect(remote_address: &str, username: &str) -> anyhow::Result<ssh2:
if !sess.authenticated() && methods.contains("publickey") {
if let Err(err) = sess.userauth_agent(&username) {
log::info!("while attempting agent auth: {}", err);
} else if sess.authenticated() {
ui.output_str("publickey auth successful!\n");
}
}
if !sess.authenticated() && methods.contains("password") {
let pass = password_prompt("", "Password", username, &remote_address)
.ok_or_else(|| anyhow!("password entry was cancelled"))?;
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") {
let mut prompt = Prompt {
username,
remote_address: &remote_address,
};
if let Err(err) = sess.userauth_keyboard_interactive(&username, &mut prompt) {
if let Err(err) = sess.userauth_keyboard_interactive(&username, ui) {
log::error!("while attempting keyboard-interactive auth: {}", err);
}
}
@ -325,6 +370,21 @@ pub fn ssh_connect(remote_address: &str, username: &str) -> anyhow::Result<ssh2:
Ok(sess)
}
pub fn ssh_connect(remote_address: &str, username: &str) -> anyhow::Result<ssh2::Session> {
let mut ui = SshUI::new();
let res = ssh_connect_with_ui(remote_address, username, &mut ui);
match res {
Ok(sess) => {
ui.close();
Ok(sess)
}
Err(err) => {
ui.output_str(&format!("\nFailed: {}", err));
Err(err)
}
}
}
pub struct RemoteSshDomain {
pty_system: Box<dyn PtySystem>,
id: DomainId,

View File

@ -13,7 +13,7 @@ use crate::mux::window::WindowId;
use crate::mux::Mux;
use anyhow::{bail, Error};
use async_trait::async_trait;
use crossbeam_channel::{unbounded as channel, Receiver, Sender};
use crossbeam::channel::{unbounded as channel, Receiver, Sender};
use filedescriptor::Pipe;
use portable_pty::*;
use rangeset::RangeSet;
@ -370,6 +370,48 @@ impl termwiz::terminal::Terminal for TermWizTerminal {
}
}
impl termwiz::terminal::Terminal for &mut TermWizTerminal {
fn set_raw_mode(&mut self) -> anyhow::Result<()> {
(**self).set_raw_mode()
}
fn set_cooked_mode(&mut self) -> anyhow::Result<()> {
(**self).set_cooked_mode()
}
fn enter_alternate_screen(&mut self) -> anyhow::Result<()> {
(**self).enter_alternate_screen()
}
fn exit_alternate_screen(&mut self) -> anyhow::Result<()> {
(**self).exit_alternate_screen()
}
fn get_screen_size(&mut self) -> anyhow::Result<ScreenSize> {
(**self).get_screen_size()
}
fn set_screen_size(&mut self, size: ScreenSize) -> anyhow::Result<()> {
(**self).set_screen_size(size)
}
fn render(&mut self, changes: &[Change]) -> anyhow::Result<()> {
(**self).render(changes)
}
fn flush(&mut self) -> anyhow::Result<()> {
(**self).flush()
}
fn poll_input(&mut self, wait: Option<Duration>) -> anyhow::Result<Option<InputEvent>> {
(**self).poll_input(wait)
}
fn waker(&self) -> TerminalWaker {
(**self).waker()
}
}
/// This function spawns a thread and constructs a GUI window with an
/// associated termwiz Terminal object to execute the provided function.
/// The function is expected to run in a loop to manage input and output
@ -378,7 +420,7 @@ impl termwiz::terminal::Terminal for TermWizTerminal {
/// the return value from the function.
pub async fn run<
T: Send + 'static,
F: Send + 'static + Fn(TermWizTerminal) -> anyhow::Result<T>,
F: Send + 'static + FnOnce(TermWizTerminal) -> anyhow::Result<T>,
>(
width: usize,
height: usize,
@ -457,6 +499,7 @@ pub async fn run<
result
}
#[allow(unused)]
pub fn message_box_ok(message: &str) {
let title = "wezterm";
let message = message.to_string();
@ -497,7 +540,7 @@ pub fn show_configuration_error_message(err: &str) {
])
.map_err(Error::msg)?;
let mut editor = LineEditor::new(term);
let mut editor = LineEditor::new(&mut term);
editor.set_prompt("(press enter to close this window)");
let mut host = NopLineEditorHost::default();