1
1
mirror of https://github.com/wez/wezterm.git synced 2024-09-20 11:17:15 +03:00

add support for unix mux via ssh

This adds an ssh domain config type that allows us to ssh to a remote
host and then proxy the unix domain mux protocol over the ssh session.
This commit is contained in:
Wez Furlong 2019-07-28 16:15:44 -07:00
parent 9d46bd889f
commit a7722beb0a
5 changed files with 327 additions and 5 deletions

View File

@ -12,6 +12,7 @@ vergen = "3"
embed-resource = "1.1"
[dependencies]
base64 = "0.10"
base91 = { path = "base91" }
bitflags = "1.0"
clipboard = "0.5"
@ -41,10 +42,13 @@ ratelimit_meter = "4.1"
rayon = "1.0"
serde = {version="1.0", features = ["rc"]}
serde_derive = "1.0"
#ssh2 = {path="../ssh2-rs"}
ssh2 = {git="https://github.com/wez/ssh2-rs", branch="kbdauth"}
structopt = "0.2"
tabout = { path = "tabout" }
term = { path = "term" }
termwiz = { path = "termwiz"}
tinyfiledialogs = "3.3"
toml = "0.4"
unicode-normalization = "0.1"
unicode-width = "0.1"

View File

@ -83,6 +83,8 @@ pub struct Config {
#[serde(default = "UnixDomain::default_unix_domains")]
pub unix_domains: Vec<UnixDomain>,
pub ssh_domains: Vec<SshDomain>,
/// When running in server mode, defines configuration for
/// each of the endpoints that we'll listen for connections
#[serde(default)]
@ -421,6 +423,23 @@ impl DaemonOptions {
}
}
#[derive(Default, Debug, Clone, Deserialize)]
pub struct SshDomain {
/// identifies the host:port pair of the remote server.
pub remote_address: String,
/// Whether agent auth should be disabled
#[serde(default)]
pub no_agent_auth: bool,
/// The username to use for authenticating with the remote host
pub username: String,
/// If true, connect to this domain automatically at startup
#[serde(default)]
pub connect_automatically: bool,
}
/// Configures an instance of a multiplexer that can be communicated
/// with via a unix domain socket
#[derive(Default, Debug, Clone, Deserialize)]
@ -565,6 +584,7 @@ impl Default for Config {
ratelimit_mux_output_pushes_per_second: None,
ratelimit_mux_output_scans_per_second: None,
unix_domains: UnixDomain::default_unix_domains(),
ssh_domains: vec![],
keys: vec![],
tls_servers: vec![],
tls_clients: vec![],

View File

@ -4,6 +4,7 @@
use failure::{err_msg, Error, Fallible};
use std::ffi::OsString;
use std::fs::DirBuilder;
use std::io::{Read, Write};
#[cfg(unix)]
use std::os::unix::fs::DirBuilderExt;
use std::path::Path;
@ -22,7 +23,7 @@ mod server;
use crate::frontend::FrontEndSelection;
use crate::mux::domain::{Domain, LocalDomain};
use crate::mux::Mux;
use crate::server::client::Client;
use crate::server::client::{unix_connect_with_retry, Client};
use crate::server::domain::{ClientDomain, ClientDomainConfig};
use portable_pty::cmdbuilder::CommandBuilder;
@ -134,6 +135,9 @@ struct CliCommand {
enum CliSubCommand {
#[structopt(name = "list", about = "list windows and tabs")]
List,
#[structopt(name = "proxy", about = "start rpc proxy pipe")]
Proxy,
}
pub fn create_user_owned_dirs(p: &Path) -> Fallible<()> {
@ -203,6 +207,16 @@ fn run_terminal_gui(config: Arc<config::Config>, opts: &StartCommand) -> Fallibl
}
}
for ssh_dom in &config.ssh_domains {
let dom = record_domain(
&mux,
ClientDomain::new(ClientDomainConfig::Ssh(ssh_dom.clone())),
)?;
if ssh_dom.connect_automatically {
dom.attach()?;
}
}
for tls_client in &config.tls_clients {
let dom = record_domain(
&mux,
@ -315,8 +329,44 @@ fn main() -> Result<(), Error> {
}
tabulate_output(&cols, &data, &mut std::io::stdout().lock())?;
}
CliSubCommand::Proxy => {
// The client object we created above will have spawned
// the server if needed, so now all we need to do is turn
// ourselves into basically netcat.
drop(client);
let unix_dom = config.unix_domains.first().unwrap();
let sock_path = unix_dom.socket_path();
let stream = unix_connect_with_retry(&sock_path)?;
// Spawn a thread to pull data from the socket and write
// it to stdout
let duped = stream.try_clone()?;
std::thread::spawn(move || {
let stdout = std::io::stdout();
consume_stream(duped, stdout.lock()).ok();
});
// and pull data from stdin and write it to the socket
let stdin = std::io::stdin();
consume_stream(stdin.lock(), stream)?;
}
}
Ok(())
}
}
}
fn consume_stream<F: Read, T: Write>(mut from_stream: F, mut to_stream: T) -> Fallible<()> {
let mut buf = [0u8; 8192];
loop {
let size = from_stream.read(&mut buf)?;
if size == 0 {
break;
}
to_stream.write_all(&buf[0..size])?;
to_stream.flush()?;
}
Ok(())
}

View File

@ -1,5 +1,5 @@
#![allow(dead_code)]
use crate::config::{Config, TlsDomainClient, UnixDomain};
use crate::config::{Config, SshDomain, TlsDomainClient, UnixDomain};
use crate::frontend::gui_executor;
use crate::mux::domain::alloc_domain_id;
use crate::mux::domain::DomainId;
@ -11,9 +11,10 @@ use crate::server::tab::ClientTab;
use crate::server::UnixStream;
use crossbeam_channel::TryRecvError;
use failure::{bail, err_msg, format_err, Fallible};
use filedescriptor::{pollfd, AsRawSocketDescriptor};
use log::info;
use promise::{Future, Promise};
use std::collections::HashMap;
use std::collections::{HashMap, HashSet};
use std::io::Write;
use std::net::TcpStream;
use std::path::Path;
@ -164,7 +165,7 @@ fn client_thread(
}
}
fn unix_connect_with_retry(path: &Path) -> Result<UnixStream, std::io::Error> {
pub fn unix_connect_with_retry(path: &Path) -> Result<UnixStream, std::io::Error> {
let mut error = std::io::Error::last_os_error();
for iter in 0..10 {
@ -185,6 +186,54 @@ struct Reconnectable {
stream: Option<Box<dyn ReadAndWrite>>,
}
struct SshStream {
chan: ssh2::Channel,
sess: ssh2::Session,
}
// This is a bit horrible, but is needed because the Channel type embeds
// a raw pointer to chan and that trips the borrow checker.
// Since we move both the session and channel together, it is safe
// to mark SshStream as Send.
unsafe impl Send for SshStream {}
impl std::io::Read for SshStream {
fn read(&mut self, buf: &mut [u8]) -> Result<usize, std::io::Error> {
self.chan.read(buf)
}
}
impl AsPollFd for SshStream {
fn as_poll_fd(&self) -> pollfd {
self.sess
.tcp_stream()
.as_ref()
.unwrap()
.as_socket_descriptor()
.as_poll_fd()
}
}
impl std::io::Write for SshStream {
fn write(&mut self, buf: &[u8]) -> Result<usize, std::io::Error> {
self.chan.write(buf)
}
fn flush(&mut self) -> Result<(), std::io::Error> {
self.chan.flush()
}
}
impl ReadAndWrite for SshStream {
fn set_non_blocking(&self, non_blocking: bool) -> Fallible<()> {
self.sess.set_blocking(!non_blocking);
Ok(())
}
fn has_read_buffered(&self) -> bool {
false
}
}
impl Reconnectable {
fn new(config: ClientDomainConfig, stream: Option<Box<dyn ReadAndWrite>>) -> Self {
Self { config, stream }
@ -205,6 +254,7 @@ impl Reconnectable {
// the set of tabs and we'd have confusing and inconsistent state
ClientDomainConfig::Unix(_) => false,
ClientDomainConfig::Tls(_) => true,
ClientDomainConfig::Ssh(_) => false,
}
}
@ -220,9 +270,193 @@ impl Reconnectable {
match self.config.clone() {
ClientDomainConfig::Unix(unix_dom) => self.unix_connect(unix_dom),
ClientDomainConfig::Tls(tls) => self.tls_connect(tls),
ClientDomainConfig::Ssh(ssh) => self.ssh_connect(ssh),
}
}
fn ssh_connect(&mut self, ssh_dom: SshDomain) -> Fallible<()> {
let mut sess = ssh2::Session::new()?;
let tcp = TcpStream::connect(&ssh_dom.remote_address)?;
sess.handshake(tcp)?;
if let Ok(mut known_hosts) = sess.known_hosts() {
let file = Path::new(&std::env::var("HOME").unwrap()).join(".ssh/known_hosts");
if file.exists() {
known_hosts
.read_file(&file, ssh2::KnownHostFileKind::OpenSSH)
.map_err(|e| {
failure::format_err!("reading known_hosts file {}: {}", file.display(), e)
})?;
}
let remote_host_name = ssh_dom.remote_address.split(':').next().ok_or_else(|| {
format_err!(
"expected remote_address to have the form 'host:port', but have {}",
ssh_dom.remote_address
)
})?;
let (key, key_type) = sess
.host_key()
.ok_or_else(|| failure::err_msg("failed to get ssh host key"))?;
let fingerprint = sess
.host_key_hash(ssh2::HashType::Sha256)
.ok_or_else(|| failure::err_msg("failed to get host fingerprint"))?;
let fingerprint = format!(
"SHA256:{}",
base64::encode_config(
fingerprint,
base64::Config::new(base64::CharacterSet::Standard, false)
)
);
use ssh2::CheckResult;
match known_hosts.check(&remote_host_name, key) {
CheckResult::Match => {}
CheckResult::NotFound => {
let allow = tinyfiledialogs::message_box_yes_no(
"wezterm",
&format!(
"SSH host {} is not yet trusted.\n\
{:?} Fingerprint: {}.\n\
Trust and continue connecting?",
ssh_dom.remote_address, key_type, fingerprint
),
tinyfiledialogs::MessageBoxIcon::Question,
tinyfiledialogs::YesNo::No,
);
if tinyfiledialogs::YesNo::No == allow {
bail!("user declined to trust host");
}
known_hosts
.add(
remote_host_name,
key,
&ssh_dom.remote_address,
key_type.into(),
)
.map_err(|e| {
failure::format_err!("adding known_hosts entry in memory: {}", e)
})?;
known_hosts
.write_file(&file, ssh2::KnownHostFileKind::OpenSSH)
.map_err(|e| {
failure::format_err!(
"writing known_hosts file {}: {}",
file.display(),
e
)
})?;
}
CheckResult::Mismatch => {
tinyfiledialogs::message_box_ok(
"wezterm",
&format!(
"host key mismatch for ssh server {}.\n\
Got fingerprint {} instead of expected value from known_hosts\n\
file {}.\n\
Refusing to connect.",
ssh_dom.remote_address,
fingerprint,
file.display()
),
tinyfiledialogs::MessageBoxIcon::Error,
);
bail!("host mismatch, man in the middle attack?!");
}
CheckResult::Failure => {
tinyfiledialogs::message_box_ok(
"wezterm",
"Failed to load and check known ssh hosts",
tinyfiledialogs::MessageBoxIcon::Error,
);
bail!("failed to check the known hosts");
}
}
}
let methods: HashSet<&str> = sess.auth_methods(&ssh_dom.username)?.split(',').collect();
if !sess.authenticated() && methods.contains("publickey") {
if let Err(err) = sess.userauth_agent(&ssh_dom.username) {
log::info!("while attempting agent auth: {}", err);
}
}
fn password_prompt(instructions: &str, prompt: &str, dom: &SshDomain) -> Option<String> {
let text = format!(
"SSH Authentication for {} @ {}\n{}\n{}",
dom.username, dom.remote_address, instructions, prompt
);
tinyfiledialogs::password_box("wezterm", &text)
}
fn input_prompt(instructions: &str, prompt: &str, dom: &SshDomain) -> Option<String> {
let text = format!(
"SSH Authentication for {} @ {}\n{}\n{}",
dom.username, dom.remote_address, instructions, prompt
);
tinyfiledialogs::input_box("wezterm", &text, "")
}
if !sess.authenticated() && methods.contains("keyboard-interactive") {
struct Prompt<'a> {
dom: &'a SshDomain,
}
let mut prompt = Prompt { dom: &ssh_dom };
impl<'a> ssh2::KeyboardInteractivePrompt for Prompt<'a> {
fn prompt<'b>(
&mut self,
_username: &str,
instructions: &str,
prompts: &[ssh2::Prompt<'b>],
) -> Vec<String> {
prompts
.iter()
.map(|p| {
let func = if p.echo {
input_prompt
} else {
password_prompt
};
func(instructions, &p.text, &self.dom).unwrap_or_else(String::new)
})
.collect()
}
}
if let Err(err) = sess.userauth_keyboard_interactive(&ssh_dom.username, &mut prompt) {
log::error!("while attempting keyboard-interactive auth: {}", err);
}
}
if !sess.authenticated() && methods.contains("password") {
let pass = password_prompt("", "Password", &ssh_dom)
.ok_or_else(|| failure::err_msg("password entry was cancelled"))?;
if let Err(err) = sess.userauth_password(&ssh_dom.username, &pass) {
log::error!("while attempting password auth: {}", err);
}
}
if !sess.authenticated() {
failure::bail!("unable to authenticate session");
}
let mut chan = sess.channel_session()?;
chan.exec("wezterm cli proxy")?;
let stream: Box<dyn ReadAndWrite> = Box::new(SshStream { sess, chan });
self.stream.replace(stream);
Ok(())
}
fn unix_connect(&mut self, unix_dom: UnixDomain) -> Fallible<()> {
let sock_path = unix_dom.socket_path();
info!("connect to {}", sock_path.display());
@ -497,6 +731,16 @@ impl Client {
Ok(Self::new(local_domain_id, reconnectable))
}
pub fn new_ssh(
local_domain_id: DomainId,
_config: &Arc<Config>,
ssh_dom: &SshDomain,
) -> Fallible<Self> {
let mut reconnectable = Reconnectable::new(ClientDomainConfig::Ssh(ssh_dom.clone()), None);
reconnectable.connect()?;
Ok(Self::new(local_domain_id, reconnectable))
}
pub fn send_pdu(&self, pdu: Pdu) -> Future<Pdu> {
let mut promise = Promise::new();
let future = promise.get_future().expect("future already taken!?");

View File

@ -1,4 +1,4 @@
use crate::config::{TlsDomainClient, UnixDomain};
use crate::config::{SshDomain, TlsDomainClient, UnixDomain};
use crate::font::{FontConfiguration, FontSystemSelection};
use crate::frontend::front_end;
use crate::mux::domain::{alloc_domain_id, Domain, DomainId, DomainState};
@ -58,6 +58,7 @@ impl ClientInner {
pub enum ClientDomainConfig {
Unix(UnixDomain),
Tls(TlsDomainClient),
Ssh(SshDomain),
}
impl ClientInner {
@ -173,6 +174,9 @@ impl Domain for ClientDomain {
ClientDomainConfig::Tls(tls) => {
Client::new_tls(self.local_domain_id, mux.config(), tls)?
}
ClientDomainConfig::Ssh(ssh) => {
Client::new_ssh(self.local_domain_id, mux.config(), ssh)?
}
};
let inner = Arc::new(ClientInner::new(self.local_domain_id, client));