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:
parent
9d46bd889f
commit
a7722beb0a
@ -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"
|
||||
|
@ -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![],
|
||||
|
52
src/main.rs
52
src/main.rs
@ -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(())
|
||||
}
|
||||
|
@ -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!?");
|
||||
|
@ -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));
|
||||
|
Loading…
Reference in New Issue
Block a user