diff options
Diffstat (limited to 'src/remote')
| -rw-r--r-- | src/remote/attach.rs (renamed from src/remote/unix.rs) | 632 | ||||
| -rw-r--r-- | src/remote/host_unix.rs | 69 |
2 files changed, 505 insertions, 196 deletions
diff --git a/src/remote/unix.rs b/src/remote/attach.rs index 6fe927c1..4121d468 100644 --- a/src/remote/unix.rs +++ b/src/remote/attach.rs @@ -1,12 +1,17 @@ //! Remote thin-client launcher over SSH command stdio. +use super::shell_quote; use std::collections::BTreeMap; use std::fs::{self, File}; use std::io::{self, IsTerminal, Write as _}; -use std::os::unix::net::{UnixListener, UnixStream}; use std::path::{Path, PathBuf}; use std::process::{Command, Output, Stdio}; +use interprocess::local_socket::traits::Listener as _; +#[cfg(windows)] +use interprocess::local_socket::traits::Stream as _; +use interprocess::local_socket::ListenerNonblockingMode; +use interprocess::TryClone as _; use serde::Deserialize; use std::sync::{ atomic::{AtomicBool, Ordering}, @@ -16,6 +21,8 @@ use std::thread::{self, JoinHandle}; use std::time::{Duration, Instant}; const BRIDGE_ACCEPT_POLL: Duration = Duration::from_millis(50); +#[cfg(windows)] +const BRIDGE_IO_POLL: Duration = Duration::from_millis(1); const BRIDGE_SOCKET_PERMISSION_MODE: u32 = 0o600; const REMOTE_SERVER_SHUTDOWN_CONFIRM_TIMEOUT: Duration = Duration::from_secs(5); const REMOTE_SERVER_SHUTDOWN_POLL_INTERVAL: Duration = Duration::from_millis(100); @@ -191,53 +198,6 @@ pub(crate) fn run_remote(remote: RemoteLaunch) -> io::Result<()> { run_client_process(&local_socket, &reattach_command, remote.keybindings) } -pub(crate) fn run_remote_client_bridge() -> io::Result<()> { - ensure_remote_server_running()?; - - let socket_path = crate::server::socket_paths::client_socket_path(); - let stream = UnixStream::connect(&socket_path).map_err(|err| { - io::Error::new( - err.kind(), - format!( - "failed to connect to remote Herdr client socket {}: {err}", - socket_path.display() - ), - ) - })?; - - let mut stdout = io::stdout().lock(); - let mut socket_to_stdout = stream.try_clone()?; - let mut stdin_to_socket = stream; - - let _upload = thread::spawn(move || { - let mut stdin = io::stdin(); - let _ = copy_flush(&mut stdin, &mut stdin_to_socket); - let _ = stdin_to_socket.shutdown(std::net::Shutdown::Write); - }); - - copy_flush(&mut socket_to_stdout, &mut stdout).map(|_| ()) -} - -fn ensure_remote_server_running() -> io::Result<()> { - let socket_path = crate::server::socket_paths::client_socket_path(); - if crate::server::autodetect::is_server_listening() { - let status = crate::api::read_runtime_status_at( - &crate::api::socket_path(), - Duration::from_millis(500), - )? - .ok_or_else(|| io::Error::other("remote server status API is unavailable"))?; - if status.protocol == Some(CURRENT_PROTOCOL) { - return Ok(()); - } - return Err(io::Error::other( - "remote herdr server must restart before this bridge can attach; rerun `herdr --remote` from an interactive terminal to approve stopping it", - )); - } - - crate::server::autodetect::spawn_server_daemon()?; - crate::server::autodetect::wait_for_server_socket(&socket_path, Duration::from_secs(5)) -} - #[derive(Debug, Clone, PartialEq, Eq)] struct RemotePlatform { os: &'static str, @@ -435,7 +395,7 @@ struct PreparedRemoteHerdr { #[derive(Clone)] struct ManagedSshOptions { config_path: PathBuf, - control_path: PathBuf, + control_path: Option<PathBuf>, } struct ManagedSshConfig { @@ -613,9 +573,14 @@ fn remote_install_commit_script(tmp_path: &str, dest_path: &str) -> String { impl Drop for RemoteSsh { fn drop(&mut self) { - if self.managed_config.is_none() { + let Some(_options) = self + .managed_config + .as_ref() + .map(|config| &config.options) + .filter(|options| options.control_path.is_some()) + else { return; - } + }; let _ = self .base_command() @@ -636,15 +601,16 @@ fn apply_managed_ssh_options(command: &mut Command, options: Option<&ManagedSshO return; }; - command - .arg("-F") - .arg(&options.config_path) - .arg("-S") - .arg(&options.control_path) - .arg("-o") - .arg("ControlMaster=auto") - .arg("-o") - .arg("ControlPersist=yes"); + command.arg("-F").arg(&options.config_path); + if let Some(control_path) = &options.control_path { + command + .arg("-S") + .arg(control_path) + .arg("-o") + .arg("ControlMaster=auto") + .arg("-o") + .arg("ControlPersist=yes"); + } } impl InstallSource { @@ -1577,14 +1543,15 @@ fn remote_release_asset(asset_key: &str) -> io::Result<RemoteReleaseAsset> { } fn private_download_dir(asset_key: &str) -> io::Result<PathBuf> { - let base = std::env::temp_dir(); + let base = crate::platform::remote_private_temp_base(); + fs::create_dir_all(&base)?; for attempt in 0..100 { let dir = base.join(format!( "herdr-remote-{}-{}-{attempt}", std::process::id(), asset_key )); - match fs::create_dir(&dir) { + match crate::platform::create_remote_private_dir(&dir) { Ok(()) => return Ok(dir), Err(err) if err.kind() == io::ErrorKind::AlreadyExists => continue, Err(err) => return Err(err), @@ -1651,8 +1618,9 @@ fn reattach_command( keybindings: RemoteKeybindings, live_handoff: bool, ) -> String { - let program = if program.is_empty() { "herdr" } else { program }; - let mut command = format!("{} --remote {}", shell_quote(program), shell_quote(target)); + let program = crate::platform::remote_reattach_program(program); + let target = crate::platform::remote_reattach_argument(target); + let mut command = format!("{program} --remote {target}"); if keybindings != RemoteKeybindings::Local { command.push_str(" --remote-keybindings "); command.push_str(keybindings.as_str()); @@ -1662,27 +1630,11 @@ fn reattach_command( } if session_name != crate::session::DEFAULT_SESSION_NAME { command.push_str(" --session "); - command.push_str(&shell_quote(session_name)); + command.push_str(&crate::platform::remote_reattach_argument(session_name)); } command } -fn shell_quote(value: &str) -> String { - if !value.is_empty() - && value.chars().all(|ch| { - ch.is_ascii_alphanumeric() - || matches!( - ch, - '@' | '%' | '_' | '+' | '=' | ':' | ',' | '.' | '/' | '-' - ) - }) - { - return value.to_string(); - } - - format!("'{}'", value.replace('\'', "'\\''")) -} - fn command_failed(context: &str, output: &Output) -> io::Error { let stderr = String::from_utf8_lossy(&output.stderr); let stderr = stderr.trim(); @@ -1695,6 +1647,7 @@ fn command_failed(context: &str, output: &Output) -> io::Error { struct SshStdioBridge { local_socket: PathBuf, + socket_identity: crate::ipc::SocketFileIdentity, should_stop: Arc<AtomicBool>, thread: Option<JoinHandle<()>>, } @@ -1707,10 +1660,21 @@ impl SshStdioBridge { session_name: String, ssh_options: Option<&ManagedSshOptions>, ) -> io::Result<Self> { - let _ = std::fs::remove_file(&local_socket); - let listener = UnixListener::bind(&local_socket)?; - crate::ipc::restrict_socket_permissions(&local_socket, BRIDGE_SOCKET_PERMISSION_MODE)?; - listener.set_nonblocking(true)?; + crate::ipc::prepare_socket_path(&local_socket, |path| { + format!("remote bridge is already listening at {}", path.display()) + })?; + let listener = crate::ipc::bind_private_local_listener(&local_socket)?; + let socket_identity = crate::ipc::socket_file_identity(&local_socket)?; + if let Err(err) = + crate::ipc::restrict_socket_permissions(&local_socket, BRIDGE_SOCKET_PERMISSION_MODE) + { + let _ = crate::ipc::remove_socket_file_if_owned(&local_socket, &socket_identity); + return Err(err); + } + if let Err(err) = listener.set_nonblocking(ListenerNonblockingMode::Accept) { + let _ = crate::ipc::remove_socket_file_if_owned(&local_socket, &socket_identity); + return Err(err); + } let should_stop = Arc::new(AtomicBool::new(false)); let thread_stop = Arc::clone(&should_stop); @@ -1718,19 +1682,14 @@ impl SshStdioBridge { let thread = thread::spawn(move || { while !thread_stop.load(Ordering::Acquire) { match listener.accept() { - Ok((stream, _addr)) => { - if let Err(err) = stream.set_nonblocking(false) { - eprintln!( - "herdr: remote bridge failed to prepare client socket: {err}" - ); - continue; - } + Ok(stream) => { if let Err(err) = bridge_connection( stream, &target, &remote_herdr, &session_name, thread_ssh_options.as_ref(), + &thread_stop, ) { eprintln!("herdr: remote bridge failed: {err}"); } @@ -1748,6 +1707,7 @@ impl SshStdioBridge { Ok(Self { local_socket, + socket_identity, should_stop, thread: Some(thread), }) @@ -1757,100 +1717,64 @@ impl SshStdioBridge { impl Drop for SshStdioBridge { fn drop(&mut self) { self.should_stop.store(true, Ordering::Release); - let _ = std::fs::remove_file(&self.local_socket); + #[cfg(unix)] + let _ = crate::ipc::remove_socket_file_if_owned(&self.local_socket, &self.socket_identity); if let Some(thread) = self.thread.take() { let _ = thread.join(); } + #[cfg(windows)] + let _ = crate::ipc::remove_socket_file_if_owned(&self.local_socket, &self.socket_identity); } } -/// Creates a fresh user-only (`0700`) directory for the generated ssh config -/// and control socket, returning its path. -/// -/// Using a private directory created with fail-if-exists semantics — rather -/// than a predictable file in the world-writable temp dir — stops a local user -/// from pre-planting a symlink or world-writable file that herdr would write -/// and `ssh -F` would then read. -fn private_ssh_config_dir() -> io::Result<PathBuf> { - use std::os::unix::fs::DirBuilderExt; - - let mut bases = vec![std::env::temp_dir()]; - let short_tmp = PathBuf::from("/tmp"); - if bases.first() != Some(&short_tmp) { - bases.push(short_tmp); - } - - let mut last_error = None; - for base in bases { - for attempt in 0..100 { - let dir = base.join(format!("herdr-ssh-{}-{attempt}", std::process::id())); - if !fits_unix_socket_path(&dir.join(SSH_CONTROL_SOCKET_NAME)) { - continue; - } - match fs::DirBuilder::new().mode(0o700).create(&dir) { - Ok(()) => return Ok(dir), - Err(err) if err.kind() == io::ErrorKind::AlreadyExists => continue, - Err(err) => { - last_error = Some(err); - break; - } - } - } - } - - Err(last_error.unwrap_or_else(|| { - io::Error::new( - io::ErrorKind::AlreadyExists, - "failed to create private herdr ssh config directory", - ) - })) -} - -/// Quotes a path for an ssh_config `Include` so a path containing spaces (or -/// glob metacharacters) is treated as one literal token instead of being split -/// or expanded by ssh — otherwise the user's config might not be Included and -/// herdr's fallback would wrongly take effect. fn ssh_config_quote(path: &str) -> String { format!("\"{path}\"") } -/// Builds a temporary ssh config for remote attach commands without overriding -/// the user's own settings, returning its path. -/// -/// The file `Include`s the user's real ssh config first, so ssh's -/// first-value-wins rule keeps any `ServerAlive*` the user set there (including -/// an explicit `0` to disable it). Herdr's keepalive values apply only when -/// the user has none. -fn write_managed_ssh_config() -> io::Result<ManagedSshConfig> { - use std::os::unix::fs::OpenOptionsExt; +fn ssh_config_include_path(path: &Path) -> String { + let path = path.to_string_lossy(); + if std::path::MAIN_SEPARATOR == '\\' { + ssh_config_quote(&path.replace('\\', "/")) + } else { + ssh_config_quote(&path) + } +} - let dir = private_ssh_config_dir()?; +/// Builds a temporary ssh config that includes the user's settings first, so +/// OpenSSH's first-value-wins behavior preserves explicit user keepalives. +fn write_managed_ssh_config() -> io::Result<ManagedSshConfig> { + let paths = crate::platform::remote_ssh_config_paths(); + let dir = crate::platform::create_remote_ssh_config_dir(SSH_CONTROL_SOCKET_NAME)?; let path = dir.join("config"); - let control_path = dir.join(SSH_CONTROL_SOCKET_NAME); + let control_path = paths + .multiplexing + .then(|| dir.join(SSH_CONTROL_SOCKET_NAME)); let mut contents = String::new(); - if let Some(home) = std::env::var_os("HOME") { - let user_config = PathBuf::from(home).join(".ssh").join("config"); - if user_config.is_file() { - contents.push_str(&format!( - "Include {}\n", - ssh_config_quote(&user_config.to_string_lossy()) - )); - } + if let Some(user_config) = paths.user_config.filter(|path| path.is_file()) { + contents.push_str(&format!( + "Include {}\n", + ssh_config_include_path(&user_config) + )); } - if Path::new("/etc/ssh/ssh_config").is_file() { - contents.push_str("Include /etc/ssh/ssh_config\n"); + if let Some(system_config) = paths.system_config.filter(|path| path.is_file()) { + contents.push_str(&format!( + "Include {}\n", + ssh_config_include_path(&system_config) + )); } contents.push_str("Host *\n"); contents.push_str(" ServerAliveInterval 15\n"); contents.push_str(" ServerAliveCountMax 4\n"); - let mut file = fs::OpenOptions::new() - .write(true) - .create_new(true) - .mode(BRIDGE_SOCKET_PERMISSION_MODE) - .open(&path)?; - file.write_all(contents.as_bytes())?; + let write_result = (|| { + let mut file = crate::platform::create_remote_ssh_config_file(&path)?; + file.write_all(contents.as_bytes()) + })(); + if let Err(err) = write_result { + let _ = fs::remove_dir_all(&dir); + return Err(err); + } Ok(ManagedSshConfig { options: ManagedSshOptions { config_path: path, @@ -1859,20 +1783,21 @@ fn write_managed_ssh_config() -> io::Result<ManagedSshConfig> { }) } +#[cfg(unix)] fn bridge_connection( - stream: UnixStream, + stream: crate::ipc::LocalStream, target: &str, remote_herdr: &RemoteHerdr, session_name: &str, ssh_options: Option<&ManagedSshOptions>, + _bridge_stop: &Arc<AtomicBool>, ) -> io::Result<()> { let mut command = Command::new("ssh"); apply_managed_ssh_options(&mut command, ssh_options); command .arg("-T") .arg(target) - .arg(remote_bridge_command(remote_herdr, session_name)); - command + .arg(remote_bridge_command(remote_herdr, session_name)) .stdin(Stdio::piped()) .stdout(Stdio::piped()) .stderr(Stdio::inherit()); @@ -1896,7 +1821,7 @@ fn bridge_connection( }); let download = thread::spawn(move || { let _ = copy_flush(&mut child_stdout, &mut child_to_stream); - let _ = child_to_stream.shutdown(std::net::Shutdown::Write); + let _ = crate::ipc::shutdown_local_stream_write(&child_to_stream); }); let status = child.wait()?; @@ -1913,6 +1838,157 @@ fn bridge_connection( } } +#[cfg(windows)] +fn bridge_connection( + stream: crate::ipc::LocalStream, + target: &str, + remote_herdr: &RemoteHerdr, + session_name: &str, + ssh_options: Option<&ManagedSshOptions>, + bridge_stop: &Arc<AtomicBool>, +) -> io::Result<()> { + let mut command = Command::new("ssh"); + apply_managed_ssh_options(&mut command, ssh_options); + command + .arg("-T") + .arg(target) + .arg(remote_bridge_command(remote_herdr, session_name)) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::inherit()); + + let mut child = command + .spawn() + .map_err(|err| io::Error::new(err.kind(), format!("failed to start ssh bridge: {err}")))?; + let mut child_stdin = match child.stdin.take() { + Some(stdin) => stdin, + None => return terminate_bridge_child(child, "ssh bridge stdin missing"), + }; + let mut child_stdout = match child.stdout.take() { + Some(stdout) => stdout, + None => return terminate_bridge_child(child, "ssh bridge stdout missing"), + }; + let stream_to_child = match stream.try_clone() { + Ok(stream) => stream, + Err(err) => { + let _ = child.kill(); + let _ = child.wait(); + return Err(err); + } + }; + if let Err(err) = stream.set_nonblocking(true) { + let _ = child.kill(); + let _ = child.wait(); + return Err(err); + } + let mut child_to_stream = stream; + + let connection_stop = Arc::new(AtomicBool::new(false)); + let upload_stop = Arc::new(AtomicBool::new(false)); + let upload_failed = Arc::new(AtomicBool::new(false)); + let download_done = Arc::new(AtomicBool::new(false)); + let client_closed = Arc::new(AtomicBool::new(false)); + let upload_cancel = Arc::clone(&upload_stop); + let upload_bridge_stop = Arc::clone(bridge_stop); + let upload_failed_worker = Arc::clone(&upload_failed); + let upload_client_closed = Arc::clone(&client_closed); + let upload = thread::spawn(move || { + let result = copy_local_stream_to_writer( + stream_to_child, + &mut child_stdin, + &upload_cancel, + &upload_bridge_stop, + &upload_client_closed, + ); + upload_failed_worker.store(result.is_err(), Ordering::Release); + result + }); + let download_stop = Arc::clone(&connection_stop); + let download_bridge_stop = Arc::clone(bridge_stop); + let download_done_worker = Arc::clone(&download_done); + let download_upload_stop = Arc::clone(&upload_stop); + let download = thread::spawn(move || { + let result = copy_reader_to_local_stream( + &mut child_stdout, + &mut child_to_stream, + &download_stop, + &download_bridge_stop, + ); + download_done_worker.store(true, Ordering::Release); + download_upload_stop.store(true, Ordering::Release); + result + }); + + let mut stopped_at = None; + let (status_result, child_exited) = loop { + match child.try_wait() { + Ok(Some(status)) => { + upload_stop.store(true, Ordering::Release); + break (Ok(status), true); + } + Ok(None) => {} + Err(err) => { + connection_stop.store(true, Ordering::Release); + upload_stop.store(true, Ordering::Release); + let _ = child.kill(); + let _ = child.wait(); + break (Err(err), false); + } + } + if bridge_stop.load(Ordering::Acquire) { + connection_stop.store(true, Ordering::Release); + upload_stop.store(true, Ordering::Release); + let _ = child.kill(); + break (child.wait(), false); + } + if client_closed.load(Ordering::Acquire) + || upload_failed.load(Ordering::Acquire) + || download_done.load(Ordering::Acquire) + { + upload_stop.store(true, Ordering::Release); + let stopped_at = stopped_at.get_or_insert_with(Instant::now); + if stopped_at.elapsed() >= Duration::from_millis(250) { + connection_stop.store(true, Ordering::Release); + let _ = child.kill(); + break (child.wait(), false); + } + } + thread::sleep(BRIDGE_ACCEPT_POLL); + }; + upload_stop.store(true, Ordering::Release); + if !child_exited { + connection_stop.store(true, Ordering::Release); + } + let upload_result = upload + .join() + .map_err(|_| io::Error::other("remote bridge upload worker panicked"))?; + let download_result = download + .join() + .map_err(|_| io::Error::other("remote bridge download worker panicked"))?; + let status = status_result?; + + let stopping = bridge_stop.load(Ordering::Acquire); + let client_closed = client_closed.load(Ordering::Acquire); + if !stopping && !client_closed { + upload_result.map_err(|err| { + io::Error::new(err.kind(), format!("remote bridge upload failed: {err}")) + })?; + download_result.map_err(|err| { + io::Error::new(err.kind(), format!("remote bridge download failed: {err}")) + })?; + } + + if status.success() || stopping || client_closed { + Ok(()) + } else { + Err(io::Error::new( + io::ErrorKind::ConnectionAborted, + format!("ssh bridge exited with {status}"), + )) + } +} + +#[cfg(unix)] fn copy_flush<R: io::Read, W: io::Write>(reader: &mut R, writer: &mut W) -> io::Result<u64> { let mut buffer = [0_u8; 16 * 1024]; let mut total = 0; @@ -1931,6 +2007,80 @@ fn copy_flush<R: io::Read, W: io::Write>(reader: &mut R, writer: &mut W) -> io:: } } +#[cfg(windows)] +fn terminate_bridge_child(mut child: std::process::Child, message: &'static str) -> io::Result<()> { + let _ = child.kill(); + let _ = child.wait(); + Err(io::Error::new(io::ErrorKind::BrokenPipe, message)) +} + +#[cfg(windows)] +fn copy_reader_to_local_stream<R: io::Read>( + reader: &mut R, + stream: &mut crate::ipc::LocalStream, + connection_stop: &AtomicBool, + bridge_stop: &AtomicBool, +) -> io::Result<u64> { + let mut buffer = [0_u8; 16 * 1024]; + let mut total = 0; + + loop { + let read = match reader.read(&mut buffer) { + Ok(0) => return Ok(total), + Ok(read) => read, + Err(err) if err.kind() == io::ErrorKind::Interrupted => continue, + Err(err) => return Err(err), + }; + let mut written = 0; + while written < read { + if connection_stop.load(Ordering::Acquire) || bridge_stop.load(Ordering::Acquire) { + return Ok(total); + } + let chunk_len = (read - written).min(4 * 1024); + match stream.write(&buffer[written..written + chunk_len]) { + Ok(0) => thread::sleep(BRIDGE_IO_POLL), + Ok(count) => written += count, + Err(err) if err.kind() == io::ErrorKind::Interrupted => continue, + Err(err) if err.kind() == io::ErrorKind::WouldBlock => { + thread::sleep(BRIDGE_IO_POLL); + } + Err(err) => return Err(err), + } + } + stream.flush()?; + total += read as u64; + } +} + +#[cfg(windows)] +fn copy_local_stream_to_writer<W: io::Write>( + mut stream: crate::ipc::LocalStream, + writer: &mut W, + connection_stop: &AtomicBool, + bridge_stop: &AtomicBool, + client_closed: &AtomicBool, +) -> io::Result<u64> { + let mut buffer = [0_u8; 16 * 1024]; + let mut total = 0; + + while !connection_stop.load(Ordering::Acquire) && !bridge_stop.load(Ordering::Acquire) { + match crate::ipc::poll_local_stream_read_count(&mut stream, &mut buffer)? { + crate::ipc::LocalStreamReadCount::Data(read) => { + writer.write_all(&buffer[..read])?; + writer.flush()?; + total += read as u64; + } + crate::ipc::LocalStreamReadCount::Pending => thread::sleep(BRIDGE_IO_POLL), + crate::ipc::LocalStreamReadCount::Closed => { + client_closed.store(true, Ordering::Release); + break; + } + } + } + + Ok(total) +} + fn run_client_process( local_socket: &Path, reattach_command: &str, @@ -1966,37 +2116,18 @@ fn local_forward_socket_path(target: &str, session_name: &str) -> PathBuf { let pid = std::process::id(); let target_clean = sanitize_path_component(target); let session_clean = sanitize_path_component(session_name); - - let tmpdir = std::env::temp_dir(); - let readable = tmpdir.join(format!( - "herdr-remote-{pid}-{target_clean}-{session_clean}.sock" - )); - if fits_unix_socket_path(&readable) { - return readable; - } - - // macOS' per-user TMPDIR (~49 chars under /var/folders/...) can push the - // readable name past sun_path's 104-byte ceiling. Fall back to a hashed - // short name in TMPDIR, then to /tmp as a last resort when TMPDIR itself - // is longer than the budget. The hash covers the full unsanitized - // target/session so uniqueness does not depend on the prefix truncation; - // the prefix is kept only for debuggability. + let readable_name = format!("herdr-remote-{pid}-{target_clean}-{session_clean}.sock"); let target_prefix: String = target_clean.chars().take(8).collect(); let hash = short_socket_hash(target, session_name); let short_name = format!("herdr-r-{pid}-{target_prefix}-{hash}.sock"); - let short_in_tmp = tmpdir.join(&short_name); - if fits_unix_socket_path(&short_in_tmp) { - return short_in_tmp; - } - PathBuf::from("/tmp").join(short_name) + crate::platform::remote_bridge_endpoint_path(&readable_name, &short_name) } +#[cfg(all(test, unix))] fn fits_unix_socket_path(path: &Path) -> bool { use std::os::unix::ffi::OsStrExt; - // sun_path is byte-limited: 104 bytes on macOS, 108 on Linux. Reserve - // 1 byte for the trailing NUL and use the smaller cap for portability. - const MAX: usize = 103; - path.as_os_str().as_bytes().len() <= MAX + + path.as_os_str().as_bytes().len() <= 103 } fn short_socket_hash(target: &str, session: &str) -> String { @@ -2028,6 +2159,7 @@ fn sanitize_path_component(input: &str) -> String { mod tests { use super::*; + #[cfg(unix)] #[test] fn bridge_socket_is_user_only() { use std::os::unix::fs::PermissionsExt; @@ -2056,13 +2188,42 @@ mod tests { let _ = std::fs::remove_file(socket); } + #[cfg(windows)] + #[test] + fn windows_bridge_drop_while_waiting_for_client_is_bounded() { + let socket = local_forward_socket_path("drop-test", "default"); + let remote_herdr = RemoteHerdr::for_platform(RemotePlatform { + os: "linux", + arch: "x86_64", + }); + let bridge = SshStdioBridge::start( + "example".to_string(), + remote_herdr, + socket.clone(), + "default".to_string(), + None, + ) + .expect("start bridge listener"); + let started = Instant::now(); + + drop(bridge); + + assert!(started.elapsed() < Duration::from_secs(1)); + assert!(!socket.exists()); + } + + #[cfg(unix)] #[test] fn managed_ssh_config_includes_user_config_then_fallback() { use std::os::unix::fs::PermissionsExt; let managed_config = write_managed_ssh_config().expect("write managed config"); let path = managed_config.options.config_path.clone(); - let control_path = managed_config.options.control_path.clone(); + let control_path = managed_config + .options + .control_path + .clone() + .expect("Unix managed config has a control path"); let contents = std::fs::read_to_string(&path).expect("read keepalive config"); // herdr's fallback transport settings are present... @@ -2124,11 +2285,16 @@ mod tests { ); } + #[cfg(unix)] #[test] fn remote_ssh_command_uses_managed_config_when_present() { let managed_config = write_managed_ssh_config().expect("write managed config"); let config_path = managed_config.options.config_path.clone(); - let control_path = managed_config.options.control_path.clone(); + let control_path = managed_config + .options + .control_path + .clone() + .expect("Unix managed config has a control path"); let ssh = RemoteSsh { target: "example".to_string(), managed_config: Some(managed_config), @@ -2157,6 +2323,45 @@ mod tests { ); } + #[cfg(windows)] + #[test] + fn windows_managed_ssh_config_uses_keepalives_without_control_socket() { + let managed_config = write_managed_ssh_config().expect("write managed config"); + let config_path = managed_config.options.config_path.clone(); + assert!(managed_config.options.control_path.is_none()); + let contents = std::fs::read_to_string(&config_path).expect("read managed config"); + assert!(contents.contains("ServerAliveInterval 15")); + assert!(contents.contains("ServerAliveCountMax 4")); + + let ssh = RemoteSsh { + target: "example".to_string(), + managed_config: Some(managed_config), + }; + let args = ssh + .command() + .get_args() + .map(|arg| arg.to_string_lossy().into_owned()) + .collect::<Vec<_>>(); + assert_eq!( + args, + vec![ + "-F".to_string(), + config_path.to_string_lossy().into_owned(), + "-T".to_string(), + "example".to_string(), + ] + ); + } + + #[cfg(windows)] + #[test] + fn windows_ssh_config_include_uses_forward_slashes() { + assert_eq!( + ssh_config_include_path(Path::new(r"C:\Users\A B\.ssh\config")), + r#""C:/Users/A B/.ssh/config""# + ); + } + #[test] fn remote_ssh_command_is_plain_without_managed_config() { let ssh = RemoteSsh { @@ -2379,6 +2584,7 @@ mod tests { assert!(RemotePlatform::from_uname("FreeBSD", "x86_64").is_none()); } + #[cfg(unix)] #[test] fn reattach_command_includes_remote_and_session() { assert_eq!( @@ -2423,6 +2629,25 @@ mod tests { ); } + #[cfg(windows)] + #[test] + fn windows_reattach_command_uses_current_executable() { + let executable = std::env::current_exe().expect("current test executable"); + assert_eq!( + reattach_command( + r"C:\Program Files\Herdr\herdr.exe", + "host'name", + "work'name", + RemoteKeybindings::Local, + false, + ), + format!( + "& '{}' --remote 'host''name' --session 'work''name'", + executable.display().to_string().replace('\'', "''") + ) + ); + } + #[test] fn remote_bridge_command_uses_installed_binary() { let remote_herdr = RemoteHerdr::for_platform(RemotePlatform { @@ -3009,16 +3234,29 @@ mod tests { assert!(source.temporary_dir.is_none()); } + #[cfg(windows)] + #[test] + fn windows_local_forward_endpoint_uses_private_state_dir() { + let path = local_forward_socket_path("user@example.com", "work"); + assert!(path.starts_with(crate::platform::remote_private_temp_base())); + assert!(path + .file_name() + .is_some_and(|name| name.to_string_lossy().starts_with("herdr-r-"))); + } + + #[cfg(unix)] fn remote_env_lock() -> &'static std::sync::Mutex<()> { static LOCK: std::sync::OnceLock<std::sync::Mutex<()>> = std::sync::OnceLock::new(); LOCK.get_or_init(|| std::sync::Mutex::new(())) } + #[cfg(unix)] fn socket_path_byte_len(path: &Path) -> usize { use std::os::unix::ffi::OsStrExt; path.as_os_str().as_bytes().len() } + #[cfg(unix)] #[test] fn local_forward_socket_path_uses_readable_name_when_it_fits() { let _guard = remote_env_lock().lock().unwrap(); @@ -3043,6 +3281,7 @@ mod tests { ); } + #[cfg(unix)] #[test] fn local_forward_socket_path_fits_in_sun_path() { let _guard = remote_env_lock().lock().unwrap(); @@ -3060,6 +3299,7 @@ mod tests { ); } + #[cfg(unix)] #[test] fn local_forward_socket_path_falls_back_to_tmp_when_dir_is_long() { let _guard = remote_env_lock().lock().unwrap(); diff --git a/src/remote/host_unix.rs b/src/remote/host_unix.rs new file mode 100644 index 00000000..e04b0e00 --- /dev/null +++ b/src/remote/host_unix.rs @@ -0,0 +1,69 @@ +//! Unix remote-host side of the SSH stdio bridge. + +use std::io; +use std::os::unix::net::UnixStream; +use std::thread; +use std::time::Duration; + +pub(crate) fn run_remote_client_bridge() -> io::Result<()> { + ensure_remote_server_running()?; + + let socket_path = crate::server::socket_paths::client_socket_path(); + let stream = UnixStream::connect(&socket_path).map_err(|err| { + io::Error::new( + err.kind(), + format!( + "failed to connect to remote Herdr client socket {}: {err}", + socket_path.display() + ), + ) + })?; + + let mut stdout = io::stdout().lock(); + let mut socket_to_stdout = stream.try_clone()?; + let mut stdin_to_socket = stream; + + let _upload = thread::spawn(move || { + let mut stdin = io::stdin(); + let _ = copy_flush(&mut stdin, &mut stdin_to_socket); + let _ = stdin_to_socket.shutdown(std::net::Shutdown::Write); + }); + + copy_flush(&mut socket_to_stdout, &mut stdout).map(|_| ()) +} + +fn copy_flush<R: io::Read, W: io::Write>(reader: &mut R, writer: &mut W) -> io::Result<u64> { + let mut buffer = [0_u8; 16 * 1024]; + let mut total = 0; + loop { + let read = match reader.read(&mut buffer) { + Ok(0) => return Ok(total), + Ok(read) => read, + Err(err) if err.kind() == io::ErrorKind::Interrupted => continue, + Err(err) => return Err(err), + }; + writer.write_all(&buffer[..read])?; + writer.flush()?; + total += read as u64; + } +} + +fn ensure_remote_server_running() -> io::Result<()> { + let socket_path = crate::server::socket_paths::client_socket_path(); + if crate::server::autodetect::is_server_listening() { + let status = crate::api::read_runtime_status_at( + &crate::api::socket_path(), + Duration::from_millis(500), + )? + .ok_or_else(|| io::Error::other("remote server status API is unavailable"))?; + if status.protocol == Some(crate::protocol::PROTOCOL_VERSION) { + return Ok(()); + } + return Err(io::Error::other( + "remote herdr server must restart before this bridge can attach; rerun `herdr --remote` from an interactive terminal to approve stopping it", + )); + } + + crate::server::autodetect::spawn_server_daemon()?; + crate::server::autodetect::wait_for_server_socket(&socket_path, Duration::from_secs(5)) +} |
