mod http_proxy;
mod ovftool;
use std::path::Path;
use tokio::io::{AsyncRead, AsyncWrite, AsyncWriteExt};
use tokio::net::{TcpListener, TcpStream};
use tokio::task::JoinHandle;
use self::http_proxy::{Targets, start_http_proxy};
const HOSTNAME: &str = "ESXI_HOSTNAME";
const HOSTPORT: &str = "ESXI_HOSTPORT";
const PROXY_COMMAND: &str = "ISOLOOM_SSH_PROXY_COMMAND";
#[derive(Debug)]
pub(crate) struct Relay {
pub(crate) port: u16,
task: JoinHandle<()>,
}
impl Drop for Relay {
fn drop(&mut self) {
self.task.abort();
}
}
#[derive(Debug, Default)]
pub(crate) struct Relayed {
pub(crate) env: Vec<(String, String)>,
_relays: Vec<Relay>,
}
fn is_this_machine(host: &str) -> bool {
let bare = host.trim_start_matches('[').trim_end_matches(']');
bare.eq_ignore_ascii_case("localhost") || bare.parse::<std::net::IpAddr>().is_ok_and(|ip| ip.is_loopback() || ip.is_unspecified())
}
pub(crate) type FromLan = Box<dyn AsyncRead + Send + Unpin>;
pub(crate) type ToLan = Box<dyn AsyncWrite + Send + Unpin>;
pub(crate) fn connect(host: &str, port: u16) -> std::io::Result<(FromLan, ToLan, tokio::process::Child)> {
let mut child = tokio::process::Command::new(NC)
.arg(host)
.arg(port.to_string())
.stdin(std::process::Stdio::piped())
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::null())
.kill_on_drop(true)
.spawn()?;
let missing = || std::io::Error::other("nc's pipes weren't opened");
let from: FromLan = Box::new(child.stdout.take().ok_or_else(missing)?);
let to: ToLan = Box::new(child.stdin.take().ok_or_else(missing)?);
Ok((from, to, child))
}
const NC: &str = "/usr/bin/nc";
pub(crate) async fn start(host: String, port: u16) -> std::io::Result<Relay> {
let listener = TcpListener::bind(("127.0.0.1", 0)).await?;
let local = listener.local_addr()?.port();
let task = serve(listener, move |inbound| {
let host = host.clone();
async move {
if let Ok((mut from, to, _nc)) = connect(&host, port) {
let (mut read, mut write) = inbound.into_split();
tokio::join!(pump_and_close(&mut read, to), pump(&mut from, &mut write));
}
}
});
Ok(Relay { port: local, task })
}
async fn pump<R, W>(from: &mut R, to: &mut W)
where
R: AsyncRead + Unpin + ?Sized,
W: AsyncWrite + Unpin + ?Sized,
{
if let Err(e) = tokio::io::copy(from, to).await {
tracing::debug!(error = %e, "a relayed connection ended early");
}
if let Err(e) = to.shutdown().await {
tracing::debug!(error = %e, "a relayed connection was already closed");
}
}
async fn pump_and_close<R>(from: &mut R, mut to: ToLan)
where
R: AsyncRead + Unpin + ?Sized,
{
pump(from, &mut to).await;
drop(to);
}
fn serve<F, Fut>(listener: TcpListener, serve: F) -> JoinHandle<()>
where
F: Fn(TcpStream) -> Fut + Send + 'static,
Fut: Future<Output = ()> + Send + 'static,
{
tokio::spawn(async move {
loop {
let Ok((inbound, _)) = listener.accept().await else {
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
continue;
};
tokio::spawn(serve(inbound));
}
})
}
pub(crate) fn esxi_host<'a>(macos: bool, vagrant: bool, provider: Option<&str>, params: &'a std::collections::BTreeMap<String, String>) -> Option<&'a str> {
let host = params.get(HOSTNAME).map(String::as_str).filter(|h| !h.is_empty() && !is_this_machine(h))?;
(macos && vagrant && provider == Some("vmware_esxi")).then_some(host)
}
pub(crate) async fn prepare(host: &str, ssh_port: u16, home: &Path, path: Option<&str>) -> std::io::Result<Relayed> {
let ssh = start(host.to_owned(), ssh_port).await?;
let https = start(host.to_owned(), 443).await?;
let mut env = vec![
(HOSTNAME.to_owned(), "127.0.0.1".to_owned()),
(HOSTPORT.to_owned(), ssh.port.to_string()),
(PROXY_COMMAND.to_owned(), "/usr/bin/nc %h %p".to_owned()),
];
let winrm = start_http_proxy(Targets::new(host)).await?;
env.push(("http_proxy".into(), format!("http://127.0.0.1:{}", winrm.port)));
env.push(("no_proxy".into(), "localhost,127.0.0.1".into()));
if let Some(real) = ovftool::real(path) {
let dir = ovftool::wrapper_dir(home)?;
env.push(("ISOTHRUM_REAL_OVFTOOL".into(), real));
env.push(("ISOTHRUM_OVFTOOL_PORT".into(), https.port.to_string()));
env.push(("PATH".into(), format!("{}:{}", dir.display(), path.unwrap_or_default())));
}
Ok(Relayed {
env,
_relays: vec![ssh, https, winrm],
})
}
#[cfg(test)]
mod tests {
use std::collections::BTreeMap;
use super::*;
#[cfg(target_os = "macos")]
#[tokio::test]
async fn a_relay_pipes_both_ways() {
use tokio::io::{AsyncReadExt, AsyncWriteExt};
let echo = TcpListener::bind(("127.0.0.1", 0)).await.unwrap();
let echo_port = echo.local_addr().unwrap().port();
tokio::spawn(async move {
let (mut s, _) = echo.accept().await.unwrap();
let mut b = [0u8; 4];
s.read_exact(&mut b).await.unwrap();
s.write_all(&b).await.unwrap();
});
let relay = start("127.0.0.1".into(), echo_port).await.unwrap();
let mut c = TcpStream::connect(("127.0.0.1", relay.port)).await.unwrap();
c.write_all(b"ping").await.unwrap();
let mut back = [0u8; 4];
c.read_exact(&mut back).await.unwrap();
assert_eq!(&back, b"ping");
}
#[cfg(target_os = "macos")]
#[tokio::test]
async fn a_client_that_closes_its_side_closes_it_at_the_host() {
use tokio::io::{AsyncReadExt, AsyncWriteExt};
let host = TcpListener::bind(("127.0.0.1", 0)).await.unwrap();
let host_port = host.local_addr().unwrap().port();
let seen = tokio::spawn(async move {
let (mut s, _) = host.accept().await.unwrap();
let mut got = Vec::new();
s.read_to_end(&mut got).await.unwrap();
got
});
let relay = start("127.0.0.1".into(), host_port).await.unwrap();
let mut c = TcpStream::connect(("127.0.0.1", relay.port)).await.unwrap();
c.write_all(b"bye").await.unwrap();
c.shutdown().await.unwrap();
let got = tokio::time::timeout(std::time::Duration::from_secs(5), seen).await;
assert_eq!(got.expect("the host saw the close").unwrap(), b"bye");
drop(relay);
}
fn params(host: &str) -> BTreeMap<String, String> {
BTreeMap::from([(HOSTNAME.to_owned(), host.to_owned())])
}
#[test]
fn only_vagrant_on_an_esxi_host_from_macos_is_relayed() {
let p = params("10.0.0.5");
assert_eq!(esxi_host(true, true, Some("vmware_esxi"), &p), Some("10.0.0.5"));
assert_eq!(esxi_host(false, true, Some("vmware_esxi"), &p), None, "not on Linux or Windows");
assert_eq!(esxi_host(true, false, Some("vmware_esxi"), &p), None, "not Terraform");
assert_eq!(esxi_host(true, true, Some("virtualbox"), &p), None);
assert_eq!(esxi_host(true, true, Some("vmware_esxi"), &BTreeMap::new()), None);
}
#[test]
fn a_host_on_this_machine_is_never_relayed() {
for host in ["127.0.0.1", "127.0.0.2", "localhost", "LOCALHOST", "::1", "[::1]", "0.0.0.0", ""] {
assert_eq!(esxi_host(true, true, Some("vmware_esxi"), ¶ms(host)), None, "{host}");
}
assert!(!is_this_machine("10.0.0.5") && !is_this_machine("esxi.lan") && !is_this_machine("[fd00::5]"));
}
#[tokio::test]
async fn a_relayed_run_points_vagrant_at_its_relays() {
let home = tempfile::tempdir().unwrap();
let r = prepare("10.0.0.5", 22, home.path(), Some("/usr/bin:/bin")).await.unwrap();
let get = |k: &str| r.env.iter().rev().find(|(n, _)| n == k).map(|(_, v)| v.clone());
assert_eq!(get(HOSTNAME).as_deref(), Some("127.0.0.1"));
assert_ne!(get(HOSTPORT).as_deref(), Some("22"));
assert_eq!(get(PROXY_COMMAND).as_deref(), Some("/usr/bin/nc %h %p"));
assert!(get("http_proxy").is_some_and(|p| p.starts_with("http://127.0.0.1:")));
}
}