//! SSH-specific implementation of [`RemoteTransport`]. //! //! [`SshTransport`] uses an existing SSH ControlMaster socket to check/install //! the remote server binary and to launch the `remote-server-proxy` process //! whose stdin/stdout become the protocol channel. use std::fmt; use std::future::Future; use std::path::{Path, PathBuf}; use std::pin::Pin; use std::sync::Arc; use anyhow::Result; use remote_server::auth::RemoteServerAuthContext; use remote_server::client::RemoteServerClient; use remote_server::manager::RemoteServerExitStatus; use remote_server::setup::{ parse_uname_output, remote_server_daemon_dir, PreinstallCheckResult, RemotePlatform, }; use remote_server::ssh::ssh_args; use remote_server::transport::{Connection, ControlPath, Error, InstallOutcome, RemoteTransport}; use warpui::r#async::executor; #[path = "ssh_transport/installation.rs"] pub(crate) mod installation; /// SSH transport: connects via a ControlMaster socket. /// /// `socket_path` is the local Unix socket created by the ControlMaster /// process (`ssh -N -o ControlMaster=yes -o ControlPath=`). All SSH /// commands (binary check, install, proxy launch) are multiplexed through /// this socket without re-authenticating. #[derive(Clone)] pub struct SshTransport { socket_path: PathBuf, auth_context: Arc, /// Whether Warp owns the ControlMaster behind `socket_path`. `false` /// when the SSH wrapper attached to a master the user already had /// running, in which case Warp must not run `ssh -O exit` against it /// on teardown. warp_owns_control_master: bool, } impl fmt::Debug for SshTransport { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { f.debug_struct("SshTransport") .field("socket_path", &self.socket_path) .field("warp_owns_control_master", &self.warp_owns_control_master) .finish_non_exhaustive() } } impl SshTransport { pub fn new( socket_path: PathBuf, auth_context: Arc, warp_owns_control_master: bool, ) -> Self { Self { socket_path, auth_context, warp_owns_control_master, } } pub fn socket_path(&self) -> &PathBuf { &self.socket_path } pub fn warp_owns_control_master(&self) -> bool { self.warp_owns_control_master } pub fn remote_daemon_socket_path(&self) -> String { format!( "{}/{}", remote_server_daemon_dir(&self.auth_context.remote_server_identity_key()), remote_server::setup::daemon_socket_name(), ) } pub fn remote_daemon_pid_path(&self) -> String { format!( "{}/{}", remote_server_daemon_dir(&self.auth_context.remote_server_identity_key()), remote_server::setup::daemon_pid_name(), ) } fn remote_proxy_command(&self) -> String { let binary = remote_server::setup::remote_server_binary(); let identity_key = self.auth_context.remote_server_identity_key(); let quoted_identity_key = shell_words::quote(&identity_key); format!("{binary} remote-server-proxy --identity-key {quoted_identity_key}") } } /// Runs `uname -sm` on the remote host via the ControlMaster socket and /// parses the output into a [`RemotePlatform`]. async fn detect_remote_platform(socket_path: &Path) -> Result { let output = remote_server::ssh::run_ssh_command( socket_path, "uname -sm", remote_server::setup::CHECK_TIMEOUT, ) .await?; if output.status.success() { let stdout = String::from_utf8_lossy(&output.stdout); parse_uname_output(&stdout) } else { let code = output.status.code().unwrap_or(-1); let stderr = String::from_utf8_lossy(&output.stderr); Err(Error::Other(anyhow::anyhow!( "uname -sm exited with code {code}: {stderr}" ))) } } impl RemoteTransport for SshTransport { fn detect_platform( &self, ) -> Pin> + Send>> { let socket_path = self.socket_path.clone(); Box::pin(async move { detect_remote_platform(&socket_path).await }) } fn run_preinstall_check( &self, ) -> Pin> + Send>> { let socket_path = self.socket_path.clone(); Box::pin(async move { match remote_server::ssh::run_ssh_script( &socket_path, remote_server::setup::PREINSTALL_CHECK_SCRIPT, remote_server::setup::CHECK_TIMEOUT, ) .await { Ok(output) if output.status.success() => { let stdout = String::from_utf8_lossy(&output.stdout); Ok(PreinstallCheckResult::parse(&stdout)) } Ok(output) => { let exit_code = output.status.code().unwrap_or(-1); let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string(); Err(Error::ScriptFailed { exit_code, stderr }) } Err(e) => Err(e.into()), } }) } fn check_binary(&self) -> Pin> + Send>> { let socket_path = self.socket_path.clone(); Box::pin(async move { let binary = remote_server::setup::remote_server_binary(); let expected_helper_version = if remote_server::setup::uses_static_linux_helper() { let platform = detect_remote_platform(&socket_path).await?; installation::local_helper_version(&platform).await } else { None }; let cmd = match expected_helper_version { Some(version) => format!( "{binary} --version >/dev/null && test \"$(cat {binary}.wormhole-version 2>/dev/null)\" = \"{version}\"" ), None => remote_server::setup::binary_check_command(), }; log::info!("Running binary check: {cmd}"); let output = remote_server::ssh::run_ssh_command( &socket_path, &cmd, remote_server::setup::CHECK_TIMEOUT, ) .await?; // ` --version` exits 0 when present, executable, and // functional. Static helpers additionally compare their bundled // build marker, where exit 1 means the remote copy is stale. // Exit 127 means the binary was not found, and 126 means it exists // but is not executable. Any other non-zero exit (e.g. SSH exit // 255 for a dead connection, or signal termination) is treated as // a transport-level failure. let code = output.status.code(); let stdout = String::from_utf8_lossy(&output.stdout); log::info!("Binary check result: exit={code:?} stdout={stdout}"); match code { Some(0) => Ok(true), Some(1) | Some(126) | Some(127) => Ok(false), Some(code) => { let stderr = String::from_utf8_lossy(&output.stderr); Err(Error::Other(anyhow::anyhow!( "binary check exited with code {code}: {stderr}" ))) } None => Err(Error::Other(anyhow::anyhow!( "binary check terminated by signal" ))), } }) } fn check_has_old_binary(&self) -> Pin> + Send>> { let socket_path = self.socket_path.clone(); Box::pin(async move { // Treat the existence of the remote-server install directory // itself as evidence of a prior install. If `~/.warp-XX/remote-server` // exists, something was installed there before, so any mismatch // with the client's expected binary path should be auto-updated // rather than surfaced as a first-time install prompt. let cmd = format!("test -d {}", remote_server::setup::remote_server_dir()); let output = remote_server::ssh::run_ssh_command( &socket_path, &cmd, remote_server::setup::CHECK_TIMEOUT, ) .await?; // `test -d` exits 0 when present, 1 when missing. // Anything else is treated as a check failure. match output.status.code() { Some(0) => Ok(true), Some(1) => Ok(false), Some(code) => { let stderr = String::from_utf8_lossy(&output.stderr); Err(anyhow::anyhow!( "remote-server dir check exited with code {code}: {stderr}" )) } None => Err(anyhow::anyhow!( "remote-server dir check terminated by signal" )), } }) } fn install_binary(&self) -> Pin + Send>> { let socket_path = self.socket_path.clone(); Box::pin(async move { installation::install_binary(&socket_path).await }) } fn connect( &self, executor: Arc, ) -> Pin> + Send>> { let socket_path = self.socket_path.clone(); let warp_owns_control_master = self.warp_owns_control_master; let remote_proxy_command = self.remote_proxy_command(); Box::pin(async move { let mut args = ssh_args(&socket_path); args.push(remote_proxy_command); // `kill_on_drop(true)` pairs with ownership of the `Child` being // returned in the [`Connection`] below: the // [`RemoteServerManager`] holds the `Child` on its per-session // state, and dropping that state (on explicit teardown or // spontaneous disconnect) sends SIGKILL to this ssh process. let mut child = command::r#async::Command::new("ssh") .args(&args) .stdin(std::process::Stdio::piped()) .stdout(std::process::Stdio::piped()) .stderr(std::process::Stdio::piped()) .kill_on_drop(true) .spawn()?; let stdin = child .stdin .take() .ok_or_else(|| anyhow::anyhow!("Failed to capture child stdin"))?; let stdout = child .stdout .take() .ok_or_else(|| anyhow::anyhow!("Failed to capture child stdout"))?; let stderr = child .stderr .take() .ok_or_else(|| anyhow::anyhow!("Failed to capture child stderr"))?; let (client, event_rx, failure_rx, host_response_rx, stderr_tail) = RemoteServerClient::from_child_streams(stdin, stdout, stderr, &executor); Ok(Connection { client, event_rx, failure_rx, host_response_rx, child, // Tag the socket with master ownership. Teardown only runs // `ssh -O exit` against Warp-managed masters; a user-owned // (external) master must be left running when the Warp // session exits. control_path: if warp_owns_control_master { ControlPath::WarpManaged(socket_path) } else { ControlPath::UserOwned(socket_path) }, stderr_tail, }) }) } fn remove_remote_server_binary( &self, ) -> Pin> + Send>> { let socket_path = self.socket_path.clone(); Box::pin(async move { let cmd = remote_server::setup::remote_server_removal_command(); log::info!("Removing stale remote server binary: {cmd}"); let output = remote_server::ssh::run_ssh_command( &socket_path, &cmd, remote_server::setup::CHECK_TIMEOUT, ) .await?; if output.status.success() { Ok(()) } else { let stderr = String::from_utf8_lossy(&output.stderr); Err(anyhow::anyhow!("Failed to remove binary: {stderr}")) } }) } /// SSH exit code 255 indicates a connection-level error (broken pipe, /// connection reset, host unreachable) — the ControlMaster's TCP /// connection is dead. A signal kill also suggests the transport was /// torn down. In either case, reconnecting through the same /// ControlMaster is futile. fn is_reconnectable(&self, exit_status: Option<&RemoteServerExitStatus>) -> bool { match exit_status { Some(s) => s.code != Some(255) && !s.signal_killed, // No exit status available — optimistically allow reconnect. None => true, } } } #[cfg(test)] #[path = "ssh_transport_tests.rs"] mod tests;