first pass of merging in warp (doesn't build)
This commit is contained in:
@@ -5,21 +5,23 @@
|
||||
//! whose stdin/stdout become the protocol channel.
|
||||
use std::fmt;
|
||||
use std::future::Future;
|
||||
use std::path::PathBuf;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::pin::Pin;
|
||||
use std::process::Stdio;
|
||||
use std::sync::Arc;
|
||||
|
||||
use anyhow::Result;
|
||||
use galaxyui::r#async::executor;
|
||||
|
||||
use remote_server::auth::RemoteServerAuthContext;
|
||||
use remote_server::client::RemoteServerClient;
|
||||
use remote_server::manager::RemoteServerExitStatus;
|
||||
use remote_server::setup::{
|
||||
self, remote_server_daemon_dir, RemotePlatform, CHECK_TIMEOUT, INSTALL_TIMEOUT,
|
||||
parse_uname_output, remote_server_daemon_dir, PreinstallCheckResult, RemotePlatform,
|
||||
};
|
||||
use remote_server::ssh::{run_ssh_command, run_ssh_script, ssh_args};
|
||||
use remote_server::transport::{Connection, RemoteTransport};
|
||||
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.
|
||||
///
|
||||
@@ -31,21 +33,32 @@ use remote_server::transport::{Connection, RemoteTransport};
|
||||
pub struct SshTransport {
|
||||
socket_path: PathBuf,
|
||||
auth_context: Arc<RemoteServerAuthContext>,
|
||||
/// 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<RemoteServerAuthContext>) -> Self {
|
||||
pub fn new(
|
||||
socket_path: PathBuf,
|
||||
auth_context: Arc<RemoteServerAuthContext>,
|
||||
warp_owns_control_master: bool,
|
||||
) -> Self {
|
||||
Self {
|
||||
socket_path,
|
||||
auth_context,
|
||||
warp_owns_control_master,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -53,17 +66,23 @@ impl SshTransport {
|
||||
&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!(
|
||||
"{}/server.sock",
|
||||
remote_server_daemon_dir(&self.auth_context.remote_server_identity_key())
|
||||
"{}/{}",
|
||||
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!(
|
||||
"{}/server.pid",
|
||||
remote_server_daemon_dir(&self.auth_context.remote_server_identity_key())
|
||||
"{}/{}",
|
||||
remote_server_daemon_dir(&self.auth_context.remote_server_identity_key()),
|
||||
remote_server::setup::daemon_pid_name(),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -75,73 +94,140 @@ impl SshTransport {
|
||||
}
|
||||
}
|
||||
|
||||
/// 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<RemotePlatform, Error> {
|
||||
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<Box<dyn Future<Output = Result<RemotePlatform, String>> + Send>> {
|
||||
) -> Pin<Box<dyn Future<Output = Result<RemotePlatform, Error>> + Send>> {
|
||||
let socket_path = self.socket_path.clone();
|
||||
Box::pin(async move { detect_remote_platform(&socket_path).await })
|
||||
}
|
||||
|
||||
fn run_preinstall_check(
|
||||
&self,
|
||||
) -> Pin<Box<dyn Future<Output = Result<PreinstallCheckResult, Error>> + Send>> {
|
||||
let socket_path = self.socket_path.clone();
|
||||
Box::pin(async move {
|
||||
match run_ssh_command(&socket_path, "uname -sm", CHECK_TIMEOUT).await {
|
||||
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);
|
||||
setup::parse_uname_output(&stdout).map_err(|e| format!("{e:#}"))
|
||||
Ok(PreinstallCheckResult::parse(&stdout))
|
||||
}
|
||||
Ok(output) => {
|
||||
let code = output.status.code().unwrap_or(-1);
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
Err(format!("uname -sm exited with code {code}: {stderr}"))
|
||||
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(format!("{e:#}")),
|
||||
Err(e) => Err(e.into()),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn check_binary(&self) -> Pin<Box<dyn Future<Output = Result<bool, String>> + Send>> {
|
||||
fn check_binary(&self) -> Pin<Box<dyn Future<Output = Result<bool, Error>> + Send>> {
|
||||
let socket_path = self.socket_path.clone();
|
||||
Box::pin(async move {
|
||||
let bin_path = setup::remote_server_binary();
|
||||
log::info!("Checking for remote server binary at {bin_path}");
|
||||
match run_ssh_command(&socket_path, &setup::binary_check_command(), CHECK_TIMEOUT).await
|
||||
{
|
||||
Ok(output) => match output.status.code() {
|
||||
Some(0) => Ok(true),
|
||||
Some(1) => Ok(false),
|
||||
Some(code) => {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
Err(format!("binary check exited with code {code}: {stderr}"))
|
||||
}
|
||||
None => Err("binary check terminated by signal".into()),
|
||||
},
|
||||
Err(e) => Err(format!("{e:#}")),
|
||||
let cmd = 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?;
|
||||
// `<binary> --version` exits 0 when present, executable, and
|
||||
// functional. 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(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 install_binary(&self) -> Pin<Box<dyn Future<Output = Result<(), String>> + Send>> {
|
||||
fn check_has_old_binary(&self) -> Pin<Box<dyn Future<Output = anyhow::Result<bool>> + Send>> {
|
||||
let socket_path = self.socket_path.clone();
|
||||
Box::pin(async move {
|
||||
let script = setup::install_script();
|
||||
log::info!(
|
||||
"Installing remote server binary to {}",
|
||||
setup::remote_server_binary()
|
||||
);
|
||||
match run_ssh_script(&socket_path, &script, INSTALL_TIMEOUT).await {
|
||||
Ok(output) if output.status.success() => Ok(()),
|
||||
Ok(output) => {
|
||||
let code = output.status.code().unwrap_or(-1);
|
||||
// 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(format!("install script failed (exit {code}): {stderr}"))
|
||||
Err(anyhow::anyhow!(
|
||||
"remote-server dir check exited with code {code}: {stderr}"
|
||||
))
|
||||
}
|
||||
Err(e) => Err(format!("{e:#}")),
|
||||
None => Err(anyhow::anyhow!(
|
||||
"remote-server dir check terminated by signal"
|
||||
)),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn install_binary(&self) -> Pin<Box<dyn Future<Output = InstallOutcome> + Send>> {
|
||||
let socket_path = self.socket_path.clone();
|
||||
Box::pin(async move { installation::install_binary(&socket_path).await })
|
||||
}
|
||||
|
||||
fn connect(
|
||||
&self,
|
||||
executor: Arc<executor::Background>,
|
||||
) -> Pin<Box<dyn Future<Output = Result<Connection>> + 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);
|
||||
@@ -154,9 +240,9 @@ impl RemoteTransport for SshTransport {
|
||||
// spontaneous disconnect) sends SIGKILL to this ssh process.
|
||||
let mut child = command::r#async::Command::new("ssh")
|
||||
.args(&args)
|
||||
.stdin(Stdio::piped())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped())
|
||||
.stdin(std::process::Stdio::piped())
|
||||
.stdout(std::process::Stdio::piped())
|
||||
.stderr(std::process::Stdio::piped())
|
||||
.kill_on_drop(true)
|
||||
.spawn()?;
|
||||
|
||||
@@ -173,39 +259,64 @@ impl RemoteTransport for SshTransport {
|
||||
.take()
|
||||
.ok_or_else(|| anyhow::anyhow!("Failed to capture child stderr"))?;
|
||||
|
||||
let (client, event_rx) =
|
||||
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,
|
||||
control_path: Some(socket_path),
|
||||
// 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<Box<dyn Future<Output = anyhow::Result<()>> + 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)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use galaxyui::r#async::BoxFuture;
|
||||
fn static_auth_context() -> Arc<RemoteServerAuthContext> {
|
||||
Arc::new(RemoteServerAuthContext::new(
|
||||
|| -> BoxFuture<'static, Option<String>> { Box::pin(async { None }) },
|
||||
|| "user id/with spaces".to_string(),
|
||||
))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remote_proxy_command_quotes_identity_key() {
|
||||
let transport = SshTransport::new(
|
||||
PathBuf::from("/tmp/control-master.sock"),
|
||||
static_auth_context(),
|
||||
);
|
||||
|
||||
let command = transport.remote_proxy_command();
|
||||
|
||||
assert!(command.contains("remote-server-proxy --identity-key"));
|
||||
assert!(command.contains("'user id/with spaces'"));
|
||||
}
|
||||
}
|
||||
#[path = "ssh_transport_tests.rs"]
|
||||
mod tests;
|
||||
|
||||
Reference in New Issue
Block a user