Initial public release of Warp.
Repo-Sync-Origin: warpdotdev/warp-internal@12af1d983b
This commit is contained in:
@@ -0,0 +1,74 @@
|
||||
// Re-export everything from the `remote_server` crate so existing
|
||||
// `crate::remote_server::*` imports in `app` continue to work.
|
||||
pub use remote_server::*;
|
||||
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
pub mod server_model;
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
pub mod ssh_transport;
|
||||
#[cfg(unix)]
|
||||
pub mod unix;
|
||||
|
||||
/// Run the `remote-server-proxy` subcommand.
|
||||
#[cfg(unix)]
|
||||
pub fn run_proxy() -> anyhow::Result<()> {
|
||||
unix::run_proxy()
|
||||
}
|
||||
|
||||
#[cfg(not(unix))]
|
||||
pub fn run_proxy() -> anyhow::Result<()> {
|
||||
anyhow::bail!("remote-server-proxy is not supported on this platform")
|
||||
}
|
||||
|
||||
/// Run the `remote-server-daemon` subcommand.
|
||||
#[cfg(unix)]
|
||||
pub fn run_daemon() -> anyhow::Result<()> {
|
||||
unix::run_daemon()
|
||||
}
|
||||
|
||||
#[cfg(not(unix))]
|
||||
pub fn run_daemon() -> anyhow::Result<()> {
|
||||
anyhow::bail!("remote-server-daemon is not supported on this platform")
|
||||
}
|
||||
|
||||
/// Start the WarpUI headless app with all daemon singleton models.
|
||||
///
|
||||
/// This is the platform-agnostic core of every `run_daemon` implementation.
|
||||
/// Platform-specific code (Unix sockets, Windows named pipes, …) binds a
|
||||
/// listener and calls this function with the appropriate `ServerModel`
|
||||
/// constructor — everything else (DirectoryWatcher, DetectedRepositories,
|
||||
/// RepoMetadataModel, FileModel) is shared.
|
||||
///
|
||||
/// # Example
|
||||
/// ```ignore
|
||||
/// // In unix/mod.rs:
|
||||
/// super::run_daemon_app(move |ctx| ServerModel::new(unix_listener, ctx))
|
||||
/// ```
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
pub(super) fn run_daemon_app(
|
||||
server_model_init: impl FnOnce(&mut warpui::ModelContext<server_model::ServerModel>) -> server_model::ServerModel
|
||||
+ 'static,
|
||||
) -> anyhow::Result<()> {
|
||||
use warpui::platform::app::AppCallbacks;
|
||||
use warpui::platform::AppBuilder;
|
||||
|
||||
AppBuilder::new_headless(AppCallbacks::default(), Box::new(()), None).run(|ctx| {
|
||||
// Rotate log files from the previous daemon invocation in the background.
|
||||
ctx.background_executor()
|
||||
.spawn(warp_logging::rotate_log_files())
|
||||
.detach();
|
||||
|
||||
use repo_metadata::repositories::DetectedRepositories;
|
||||
use repo_metadata::watcher::DirectoryWatcher;
|
||||
use repo_metadata::RepoMetadataModel;
|
||||
// Order matters: DetectedRepositories must be registered before
|
||||
// RepoMetadataModel because LocalRepoMetadataModel::new()
|
||||
// subscribes to DetectedRepositories::handle(ctx).
|
||||
ctx.add_singleton_model(DirectoryWatcher::new);
|
||||
ctx.add_singleton_model(|_ctx| DetectedRepositories::default());
|
||||
ctx.add_singleton_model(RepoMetadataModel::new_with_incremental_updates);
|
||||
ctx.add_singleton_model(warp_files::FileModel::new);
|
||||
ctx.add_singleton_model(server_model_init);
|
||||
})?;
|
||||
Ok(())
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,150 @@
|
||||
//! 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::path::PathBuf;
|
||||
|
||||
use anyhow::Result;
|
||||
use warpui::r#async::executor;
|
||||
|
||||
use remote_server::client::RemoteServerClient;
|
||||
use remote_server::setup::RemotePlatform;
|
||||
use remote_server::transport::{Connection, RemoteTransport};
|
||||
|
||||
/// 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=<path>`). All SSH
|
||||
/// commands (binary check, install, proxy launch) are multiplexed through
|
||||
/// this socket without re-authenticating.
|
||||
#[derive(Clone)]
|
||||
pub struct SshTransport {
|
||||
socket_path: PathBuf,
|
||||
}
|
||||
|
||||
impl SshTransport {
|
||||
pub fn new(socket_path: PathBuf) -> Self {
|
||||
Self { socket_path }
|
||||
}
|
||||
}
|
||||
|
||||
impl RemoteTransport for SshTransport {
|
||||
async fn detect_platform(&self) -> Result<RemotePlatform, String> {
|
||||
match remote_server::ssh::run_ssh_command(
|
||||
&self.socket_path,
|
||||
"uname -sm",
|
||||
remote_server::setup::CHECK_TIMEOUT,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(output) if output.status.success() => {
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
remote_server::setup::parse_uname_output(&stdout).map_err(|e| format!("{e:#}"))
|
||||
}
|
||||
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}"))
|
||||
}
|
||||
Err(e) => Err(format!("{e:#}")),
|
||||
}
|
||||
}
|
||||
|
||||
async fn check_binary(&self) -> Result<bool, String> {
|
||||
let bin_path = remote_server::setup::remote_server_binary();
|
||||
log::info!("Checking for remote server binary at {bin_path}");
|
||||
match remote_server::ssh::run_ssh_command(
|
||||
&self.socket_path,
|
||||
&remote_server::setup::binary_check_command(),
|
||||
remote_server::setup::CHECK_TIMEOUT,
|
||||
)
|
||||
.await
|
||||
{
|
||||
// `test -x` exits 0 when present, 1 when missing.
|
||||
// Any other exit code (or None / signal) is treated as a check failure.
|
||||
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:#}")),
|
||||
}
|
||||
}
|
||||
|
||||
async fn install_binary(&self) -> Result<(), String> {
|
||||
let script = remote_server::setup::install_script();
|
||||
log::info!(
|
||||
"Installing remote server binary to {}",
|
||||
remote_server::setup::remote_server_binary()
|
||||
);
|
||||
match remote_server::ssh::run_ssh_script(
|
||||
&self.socket_path,
|
||||
&script,
|
||||
remote_server::setup::INSTALL_TIMEOUT,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(output) if output.status.success() => Ok(()),
|
||||
Ok(output) => {
|
||||
let code = output.status.code().unwrap_or(-1);
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
Err(format!("install script failed (exit {code}): {stderr}"))
|
||||
}
|
||||
Err(e) => Err(format!("{e:#}")),
|
||||
}
|
||||
}
|
||||
|
||||
async fn connect(&self, executor: &executor::Background) -> Result<Connection> {
|
||||
let binary = remote_server::setup::remote_server_binary();
|
||||
let mut args = remote_server::ssh::ssh_args(&self.socket_path);
|
||||
args.push(format!("{binary} remote-server-proxy"));
|
||||
|
||||
// `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.
|
||||
// Without this the ssh child is orphaned and keeps a channel
|
||||
// open on the ControlMaster socket, blocking the master from
|
||||
// exiting cleanly when the user logs out.
|
||||
//
|
||||
// Note that the child's lifetime is decoupled from any
|
||||
// `Arc<RemoteServerClient>` clones: other owners (e.g. the
|
||||
// per-session command executor) can keep the client alive for
|
||||
// their own purposes without pinning the subprocess.
|
||||
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) =
|
||||
RemoteServerClient::from_child_streams(stdin, stdout, stderr, executor);
|
||||
Ok(Connection {
|
||||
client,
|
||||
event_rx,
|
||||
child,
|
||||
control_path: Some(self.socket_path.clone()),
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,226 @@
|
||||
//! Unix-specific implementation of the remote server daemon and proxy.
|
||||
//!
|
||||
//! - `run_proxy()`: entry point for the `remote-server-proxy` subcommand.
|
||||
//! Uses a ControlMaster-like pattern (flock + fork + exec) to daemonize
|
||||
//! the server and bridge the SSH stdio channel to its Unix socket.
|
||||
//!
|
||||
//! - `run_daemon()`: entry point for the `remote-server-daemon` subcommand.
|
||||
//! Binds a Unix domain socket, accepts multiple concurrent proxy connections,
|
||||
//! and exits after a grace period with no connections.
|
||||
//!
|
||||
//! All platform-specific code is contained here so that the parent `mod.rs`
|
||||
//! is a thin dispatcher with no Unix assumptions.
|
||||
|
||||
mod proxy;
|
||||
|
||||
use super::server_model::{ConnectionId, ServerModel};
|
||||
use warpui::r#async::executor;
|
||||
|
||||
/// Run the `remote-server-proxy` subcommand.
|
||||
///
|
||||
/// Ensures the daemon is running (starting it if necessary), then bridges
|
||||
/// this process's stdin/stdout to the daemon's Unix socket for the lifetime
|
||||
/// of the SSH session.
|
||||
pub fn run_proxy() -> anyhow::Result<()> {
|
||||
env_logger::Builder::from_default_env()
|
||||
.target(env_logger::Target::Stderr)
|
||||
.init();
|
||||
proxy::run()
|
||||
}
|
||||
|
||||
/// Run the `remote-server-daemon` subcommand.
|
||||
///
|
||||
/// Binds a Unix domain socket and writes a PID file, then delegates the
|
||||
/// WarpUI app startup to [`super::run_daemon_app`] with the Unix-specific
|
||||
/// `ServerModel` constructor.
|
||||
pub fn run_daemon() -> anyhow::Result<()> {
|
||||
// Log to a rotating file so daemon output is preserved across invocations.
|
||||
// The file is written to the same directory as client logs (~/Library/Logs
|
||||
// on macOS, ~/.local/share/warp-terminal on Linux). Since the daemon runs
|
||||
// on the remote host, there is no conflict with client-side log files.
|
||||
warp_logging::init(warp_logging::LogConfig {
|
||||
is_cli: true,
|
||||
log_destination: Some(warp_logging::LogDestination::File),
|
||||
})?;
|
||||
|
||||
// socket_path: ~/.warp[-channel]/remote-server/server.sock
|
||||
// The Unix domain socket the daemon binds on. Proxy processes connect
|
||||
// to it and bridge their SSH stdio channel through it.
|
||||
//
|
||||
// pid_path: ~/.warp[-channel]/remote-server/server.pid
|
||||
// Contains the daemon's PID. Proxy processes read it and use
|
||||
// kill(pid, 0) to detect whether the daemon is still alive before
|
||||
// deciding whether to start a new one.
|
||||
let socket_path = proxy::socket_path();
|
||||
let pid_path = proxy::pid_path();
|
||||
|
||||
if let Some(parent) = socket_path.parent() {
|
||||
std::fs::create_dir_all(parent)?;
|
||||
}
|
||||
if socket_path.exists() {
|
||||
std::fs::remove_file(&socket_path)?;
|
||||
}
|
||||
|
||||
// Bind with std (no async runtime needed yet); converted to
|
||||
// async_io::Async inside the closure where the executor is active.
|
||||
let listener = std::os::unix::net::UnixListener::bind(&socket_path)?;
|
||||
// async_io::Async::new() requires non-blocking mode.
|
||||
listener.set_nonblocking(true)?;
|
||||
log::info!("Daemon bound to {}", socket_path.display());
|
||||
|
||||
std::fs::write(&pid_path, std::process::id().to_string())?;
|
||||
|
||||
super::run_daemon_app(move |ctx| {
|
||||
// Spawn the Unix socket accept loop. The listener and connection
|
||||
// handling are entirely Unix-specific; ServerModel itself is
|
||||
// platform-agnostic and only sees register_connection /
|
||||
// deregister_connection calls.
|
||||
let spawner = ctx.spawner();
|
||||
let exec = ctx.background_executor();
|
||||
let spawner_loop = spawner.clone();
|
||||
let background_executor = exec.clone();
|
||||
|
||||
exec.spawn(async move {
|
||||
let listener = match async_io::Async::new(listener) {
|
||||
Ok(l) => l,
|
||||
Err(e) => {
|
||||
log::error!("Daemon: async listener error: {e}");
|
||||
return;
|
||||
}
|
||||
};
|
||||
loop {
|
||||
match listener.accept().await {
|
||||
Ok((stream, _)) => {
|
||||
let conn_id = uuid::Uuid::new_v4();
|
||||
log::info!("Daemon: accepted connection {conn_id}");
|
||||
let spawner = spawner_loop.clone();
|
||||
background_executor
|
||||
.spawn(handle_daemon_connection(
|
||||
conn_id,
|
||||
stream,
|
||||
spawner,
|
||||
background_executor.clone(),
|
||||
))
|
||||
.detach();
|
||||
}
|
||||
Err(e) => log::error!("Daemon: accept error: {e}"),
|
||||
}
|
||||
}
|
||||
})
|
||||
.detach();
|
||||
|
||||
ServerModel::new(ctx)
|
||||
})?;
|
||||
|
||||
let _ = std::fs::remove_file(&socket_path);
|
||||
let _ = std::fs::remove_file(&pid_path);
|
||||
log::info!("Daemon exiting");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Handles a single Unix socket connection from a proxy process.
|
||||
///
|
||||
/// Spawns a dedicated **reader task** that owns the read half of the socket
|
||||
/// and runs a tight `read_client_message` loop, forwarding each decoded
|
||||
/// message to `ServerModel` via the spawner. The reader is never cancelled
|
||||
/// mid-read, which avoids the framing desynchronisation that would occur if
|
||||
/// `read_client_message` were polled inside a `select!` branch.
|
||||
///
|
||||
/// The calling task becomes the **writer loop**: it drains the per-connection
|
||||
/// outbound channel (`conn_rx`) and writes each `ServerMessage` to the socket.
|
||||
/// When the reader exits (EOF / error) it calls `deregister_connection`, which
|
||||
/// drops `conn_tx` from `ServerModel` and causes `conn_rx` to close, naturally
|
||||
/// terminating the writer loop.
|
||||
pub(super) async fn handle_daemon_connection(
|
||||
conn_id: ConnectionId,
|
||||
stream: async_io::Async<std::os::unix::net::UnixStream>,
|
||||
spawner: warpui::ModelSpawner<ServerModel>,
|
||||
exec: std::sync::Arc<executor::Background>,
|
||||
) {
|
||||
use futures::io::{AsyncWriteExt, BufReader, BufWriter};
|
||||
use futures::AsyncReadExt as _;
|
||||
|
||||
let (conn_tx, conn_rx) = async_channel::unbounded::<remote_server::proto::ServerMessage>();
|
||||
|
||||
// Register with ServerModel (cancels grace timer if running).
|
||||
let _ = spawner
|
||||
.spawn({
|
||||
let conn_tx_reg = conn_tx.clone();
|
||||
move |me, ctx| {
|
||||
me.register_connection(conn_id, conn_tx_reg, ctx);
|
||||
}
|
||||
})
|
||||
.await;
|
||||
|
||||
let (read_half, write_half) = stream.split();
|
||||
let mut writer = BufWriter::new(write_half);
|
||||
|
||||
// ---- Reader task -------------------------------------------------------
|
||||
// Owns the read half; dispatches decoded messages to ServerModel.
|
||||
// On exit it calls deregister_connection, which drops conn_tx from
|
||||
// ServerModel and closes conn_rx, terminating the writer loop below.
|
||||
let spawner_reader = spawner.clone();
|
||||
exec.spawn(async move {
|
||||
let mut reader = BufReader::new(read_half);
|
||||
loop {
|
||||
match remote_server::protocol::read_client_message(&mut reader).await {
|
||||
Ok(msg) => {
|
||||
let result = spawner_reader
|
||||
.spawn(move |me, ctx| {
|
||||
me.handle_message(conn_id, msg, ctx);
|
||||
})
|
||||
.await;
|
||||
if result.is_err() {
|
||||
log::warn!("Daemon: ServerModel dropped, closing conn {conn_id}");
|
||||
break;
|
||||
}
|
||||
}
|
||||
Err(remote_server::protocol::ProtocolError::UnexpectedEof) => {
|
||||
log::info!("Daemon: proxy {conn_id} disconnected (EOF)");
|
||||
break;
|
||||
}
|
||||
Err(e) if e.is_read_recoverable() => {
|
||||
log::warn!("Daemon: skipping malformed message from conn {conn_id}: {e}");
|
||||
}
|
||||
Err(e) => {
|
||||
log::error!("Daemon: fatal read error from conn {conn_id}: {e}");
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
// Deregistering drops conn_tx from ServerModel, closing conn_rx and
|
||||
// causing the writer loop to exit naturally.
|
||||
let _ = spawner_reader
|
||||
.spawn(move |me, ctx| {
|
||||
me.deregister_connection(conn_id, ctx);
|
||||
})
|
||||
.await;
|
||||
})
|
||||
.detach();
|
||||
|
||||
// ---- Writer loop -------------------------------------------------------
|
||||
// Drains outbound messages until conn_rx closes (reader called
|
||||
// deregister_connection) or a fatal write error occurs.
|
||||
while let Ok(msg) = conn_rx.recv().await {
|
||||
if let Err(e) = remote_server::protocol::write_server_message(&mut writer, &msg).await {
|
||||
log::error!("Daemon: write error on conn {conn_id}: {e}");
|
||||
break;
|
||||
}
|
||||
// Flush after every message so responses reach the proxy without
|
||||
// waiting for the BufWriter's internal buffer to fill up.
|
||||
if let Err(e) = writer.flush().await {
|
||||
log::error!("Daemon: flush error on conn {conn_id}: {e}");
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
let _ = writer.flush().await;
|
||||
|
||||
// Deregister in case the writer exited due to a write error before the
|
||||
// reader task called deregister. This is a no-op if already deregistered.
|
||||
let _ = spawner
|
||||
.spawn(move |me, ctx| {
|
||||
me.deregister_connection(conn_id, ctx);
|
||||
})
|
||||
.await;
|
||||
}
|
||||
@@ -0,0 +1,287 @@
|
||||
//! Remote server proxy — runs over SSH stdio and bridges to the long-lived
|
||||
//! daemon process via a Unix domain socket.
|
||||
//!
|
||||
//! Responsibilities:
|
||||
//! 1. Acquire an exclusive `flock` on the PID file to serialise concurrent
|
||||
//! proxy starts (e.g. two tabs SSH-ing to the same host at the same time).
|
||||
//! 2. Check whether the daemon is already running (`kill -0`).
|
||||
//! 3. If not: spawn the daemon subcommand in a new session and wait for its
|
||||
//! socket to appear.
|
||||
//! 4. Connect to `server.sock` and bridge stdin/stdout to the socket using
|
||||
//! the existing 4-byte length-prefixed frame format.
|
||||
|
||||
use std::os::unix::io::AsRawFd;
|
||||
use std::path::PathBuf;
|
||||
use std::process::Stdio;
|
||||
use std::time::Duration;
|
||||
|
||||
use super::super::setup;
|
||||
|
||||
/// Path to the daemon's Unix domain socket.
|
||||
pub(super) fn socket_path() -> PathBuf {
|
||||
let dir = setup::remote_server_dir();
|
||||
let expanded = shellexpand::tilde(&dir).into_owned();
|
||||
PathBuf::from(expanded).join("server.sock")
|
||||
}
|
||||
|
||||
/// Path to the daemon's PID file (also used as the flock target).
|
||||
pub(super) fn pid_path() -> PathBuf {
|
||||
let dir = setup::remote_server_dir();
|
||||
let expanded = shellexpand::tilde(&dir).into_owned();
|
||||
PathBuf::from(expanded).join("server.pid")
|
||||
}
|
||||
|
||||
/// Entry point for `remote-server-proxy`.
|
||||
///
|
||||
/// Ensures the daemon is running, then bridges stdin/stdout to the daemon's
|
||||
/// Unix socket for the lifetime of this SSH session.
|
||||
pub fn run() -> anyhow::Result<()> {
|
||||
let socket_path = socket_path();
|
||||
let pid_path = pid_path();
|
||||
|
||||
// Ensure the parent directory exists.
|
||||
if let Some(parent) = socket_path.parent() {
|
||||
std::fs::create_dir_all(parent)?;
|
||||
}
|
||||
|
||||
// ---- Acquire exclusive flock on the PID file --------------------------------
|
||||
//
|
||||
// This serialises concurrent proxy starts. If two tabs SSH in at the
|
||||
// same time and both see "no daemon running", only one will succeed in
|
||||
// forking a daemon; the other will block here, then connect to the one
|
||||
// the first proxy started.
|
||||
//
|
||||
// The lock is released automatically when the File is dropped.
|
||||
let pid_file = std::fs::OpenOptions::new()
|
||||
.create(true)
|
||||
.truncate(false)
|
||||
.read(true)
|
||||
.write(true)
|
||||
.open(&pid_path)?;
|
||||
let pid_fd = pid_file.as_raw_fd();
|
||||
flock_wait(pid_fd, libc::LOCK_EX)?;
|
||||
|
||||
// ---- Check whether daemon is already running --------------------------------
|
||||
let daemon_running = check_daemon_running(&pid_path);
|
||||
if daemon_running {
|
||||
log::info!("Proxy: reusing existing daemon");
|
||||
} else {
|
||||
log::info!("Proxy: no daemon running, will start one");
|
||||
}
|
||||
|
||||
if !daemon_running {
|
||||
// Remove any stale socket from a previous crash.
|
||||
if socket_path.exists() {
|
||||
let _ = std::fs::remove_file(&socket_path);
|
||||
}
|
||||
|
||||
// Spawn the daemon in a new Unix session so it is detached from
|
||||
// the SSH session. When SSH exits the OS sends SIGHUP to every
|
||||
// process in the session's foreground process group. `setsid()`
|
||||
// creates a new session for the child, so the daemon is not in
|
||||
// SSH's process group and will not receive that signal.
|
||||
let exe = std::env::current_exe()?;
|
||||
let mut cmd = command::blocking::Command::new(&exe);
|
||||
cmd.arg("remote-server-daemon")
|
||||
.stdin(Stdio::null())
|
||||
.stdout(Stdio::null())
|
||||
.stderr(Stdio::null());
|
||||
// SAFETY: setsid(2) is async-signal-safe and has no side effects
|
||||
// other than creating a new session. pre_exec closures run between
|
||||
// fork and exec in the child process.
|
||||
unsafe {
|
||||
cmd.pre_exec(|| {
|
||||
libc::setsid();
|
||||
Ok(())
|
||||
});
|
||||
}
|
||||
cmd.spawn()
|
||||
.map_err(|e| anyhow::anyhow!("failed to spawn daemon: {e}"))?;
|
||||
|
||||
// Wait for the daemon's socket to appear before releasing the flock.
|
||||
// Holding the lock here prevents a concurrent proxy from acquiring it,
|
||||
// reading a stale PID file, and racing to spawn a second daemon.
|
||||
wait_for_socket(&socket_path)?;
|
||||
|
||||
flock_wait(pid_fd, libc::LOCK_UN)?;
|
||||
drop(pid_file);
|
||||
} else {
|
||||
// Daemon already running — release the flock and connect.
|
||||
flock_wait(pid_fd, libc::LOCK_UN)?;
|
||||
drop(pid_file);
|
||||
}
|
||||
|
||||
// ---- Bridge stdin/stdout to the daemon socket --------------------------------
|
||||
bridge_stdio_to_socket(&socket_path)
|
||||
}
|
||||
|
||||
/// Returns true if the PID stored in `pid_path` belongs to a live process.
|
||||
fn check_daemon_running(pid_path: &std::path::Path) -> bool {
|
||||
let Ok(contents) = std::fs::read_to_string(pid_path) else {
|
||||
return false;
|
||||
};
|
||||
let Ok(pid) = contents.trim().parse::<libc::pid_t>() else {
|
||||
return false;
|
||||
};
|
||||
// kill(pid, 0) succeeds (returns 0) if the process exists and we can
|
||||
// signal it; it fails with ESRCH if the process does not exist.
|
||||
// SAFETY: sending signal 0 is always safe — it performs a permission
|
||||
// check only and does not deliver an actual signal.
|
||||
unsafe { libc::kill(pid, 0) == 0 }
|
||||
}
|
||||
|
||||
/// Poll until the daemon's socket file appears or the timeout elapses.
|
||||
///
|
||||
/// After we spawn the daemon there is a race: the daemon needs time to bind
|
||||
/// and listen on the socket before the proxy can connect to it. We poll
|
||||
/// until the socket file is present rather than connecting immediately,
|
||||
/// which would fail with "no such file" if the daemon hasn't started yet.
|
||||
fn wait_for_socket(socket_path: &std::path::Path) -> anyhow::Result<()> {
|
||||
const TIMEOUT: Duration = Duration::from_secs(10);
|
||||
const POLL_INTERVAL: Duration = Duration::from_millis(20);
|
||||
let start = instant::Instant::now();
|
||||
while !socket_path.exists() {
|
||||
if start.elapsed() >= TIMEOUT {
|
||||
anyhow::bail!(
|
||||
"timed out waiting for daemon socket at {}",
|
||||
socket_path.display()
|
||||
);
|
||||
}
|
||||
std::thread::sleep(POLL_INTERVAL);
|
||||
}
|
||||
log::info!("Proxy: daemon socket ready after {:?}", start.elapsed());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Calls `flock(2)` with the given operation, retrying on `EINTR`.
|
||||
///
|
||||
/// Blocking `flock(LOCK_EX)` can be interrupted by a signal before acquiring
|
||||
/// the lock; ignoring the return value would cause the proxy to proceed
|
||||
/// without actually holding the lock.
|
||||
fn flock_wait(fd: std::os::unix::io::RawFd, operation: libc::c_int) -> anyhow::Result<()> {
|
||||
loop {
|
||||
// SAFETY: flock(2) is safe to call with a valid fd and a valid operation.
|
||||
let ret = unsafe { libc::flock(fd, operation) };
|
||||
if ret == 0 {
|
||||
return Ok(());
|
||||
}
|
||||
let err = std::io::Error::last_os_error();
|
||||
if err.raw_os_error() == Some(libc::EINTR) {
|
||||
continue; // Interrupted by signal — retry.
|
||||
}
|
||||
return Err(anyhow::anyhow!("flock failed: {err}"));
|
||||
}
|
||||
}
|
||||
|
||||
/// Connect to the daemon's Unix socket and copy bytes bidirectionally between
|
||||
/// stdin/stdout and the socket.
|
||||
///
|
||||
/// The proxy is protocol-agnostic — it forwards raw bytes without parsing the
|
||||
/// length-prefixed framing. The framing is handled at the endpoints (Warp
|
||||
/// client and daemon).
|
||||
///
|
||||
/// **Important**: the stdout direction uses a manual read→write→flush loop
|
||||
/// instead of `io::copy` because `std::io::stdout()` wraps the fd in a
|
||||
/// `LineWriter` that only flushes up to the last `\n` byte in each write.
|
||||
/// For a binary protocol the trailing bytes after the last `0x0a` get stuck
|
||||
/// in the internal `BufWriter` and are never flushed, causing the client to
|
||||
/// hang forever waiting for complete messages.
|
||||
///
|
||||
/// **Shutdown coordination**: each direction explicitly
|
||||
/// [`shutdown(Both)`s][Shutdown] the Unix socket when its copy loop
|
||||
/// returns, which unblocks the other thread's read/write on the same
|
||||
/// underlying socket. Without this, when the client SIGKILLs the local
|
||||
/// `ssh ... remote-server-proxy` slave (e.g. on `ExitShell`), sshd
|
||||
/// closes our stdin but the daemon has no reason to close its end of
|
||||
/// the Unix socket, so the stdout thread sits forever in a blocking
|
||||
/// read. That keeps the proxy alive with stdout still open, which
|
||||
/// keeps the SSH channel half-closed on the server side, which in
|
||||
/// turn keeps the client's `ssh` ControlMaster from exiting until
|
||||
/// sshd's session cleanup eventually fires. Shutting the Unix socket
|
||||
/// here makes teardown deterministic and independent of whatever the
|
||||
/// daemon is doing.
|
||||
///
|
||||
/// [Shutdown]: std::net::Shutdown
|
||||
fn bridge_stdio_to_socket(socket_path: &std::path::Path) -> anyhow::Result<()> {
|
||||
use std::io::{Read, Write};
|
||||
use std::net::Shutdown;
|
||||
|
||||
log::info!(
|
||||
"Proxy: connecting to daemon socket at {}",
|
||||
socket_path.display()
|
||||
);
|
||||
let stream = std::os::unix::net::UnixStream::connect(socket_path)?;
|
||||
log::info!("Proxy: connected, bridging stdio");
|
||||
|
||||
// Each thread holds two clones: one it actively reads/writes, and
|
||||
// one used solely to `shutdown(Both)` on exit so the peer thread's
|
||||
// blocking call returns. `UnixStream::try_clone` shares the
|
||||
// underlying socket, so `shutdown` on any clone tears down both
|
||||
// directions for every clone.
|
||||
let stream_for_t1 = stream.try_clone()?;
|
||||
let stream_shutdown_for_t1 = stream.try_clone()?;
|
||||
let stream_for_t2 = stream.try_clone()?;
|
||||
let stream_shutdown_for_t2 = stream.try_clone()?;
|
||||
drop(stream);
|
||||
|
||||
let t1 = std::thread::Builder::new()
|
||||
.name("proxy-stdin-fwd".into())
|
||||
.spawn(move || {
|
||||
let result = std::io::copy(&mut std::io::stdin(), &mut &stream_for_t1);
|
||||
match &result {
|
||||
Ok(total) => log::info!(
|
||||
"Proxy: stdin->socket copy ended ({total} bytes); \
|
||||
shutting down socket to unblock peer"
|
||||
),
|
||||
Err(e) => log::info!(
|
||||
"Proxy: stdin->socket copy errored ({e}); \
|
||||
shutting down socket to unblock peer"
|
||||
),
|
||||
}
|
||||
let _ = stream_shutdown_for_t1.shutdown(Shutdown::Both);
|
||||
result
|
||||
})?;
|
||||
|
||||
// Socket → stdout: flush after every write so that complete protocol
|
||||
// frames reach the SSH tunnel without waiting for the `LineWriter`
|
||||
// buffer to fill.
|
||||
let t2 = std::thread::Builder::new()
|
||||
.name("proxy-stdout-fwd".into())
|
||||
.spawn(move || -> std::io::Result<u64> {
|
||||
let mut stdout = std::io::stdout().lock();
|
||||
let mut buf = [0u8; 8192];
|
||||
let mut total = 0u64;
|
||||
let result = loop {
|
||||
let n = match (&stream_for_t2).read(&mut buf) {
|
||||
Ok(0) => break Ok(total),
|
||||
Ok(n) => n,
|
||||
Err(e) => break Err(e),
|
||||
};
|
||||
if let Err(e) = stdout.write_all(&buf[..n]) {
|
||||
break Err(e);
|
||||
}
|
||||
if let Err(e) = stdout.flush() {
|
||||
break Err(e);
|
||||
}
|
||||
total += n as u64;
|
||||
};
|
||||
match &result {
|
||||
Ok(total) => log::info!(
|
||||
"Proxy: socket->stdout copy ended ({total} bytes); \
|
||||
shutting down socket to unblock peer"
|
||||
),
|
||||
Err(e) => log::info!(
|
||||
"Proxy: socket->stdout copy errored ({e}); \
|
||||
shutting down socket to unblock peer"
|
||||
),
|
||||
}
|
||||
let _ = stream_shutdown_for_t2.shutdown(Shutdown::Both);
|
||||
result
|
||||
})?;
|
||||
|
||||
let _ = t1.join();
|
||||
let _ = t2.join();
|
||||
|
||||
log::info!("Proxy: bridge closed, exiting");
|
||||
Ok(())
|
||||
}
|
||||
Reference in New Issue
Block a user