mirror of
https://github.com/herdrdev/herdr.git
synced 2026-11-06 00:06:18 +00:00
487 lines
18 KiB
Rust
487 lines
18 KiB
Rust
use std::io;
|
|
#[cfg(unix)]
|
|
use std::io::IsTerminal as _;
|
|
use std::sync::atomic::AtomicBool;
|
|
use std::time::{Duration, Instant};
|
|
|
|
use interprocess::local_socket::traits::Stream as _;
|
|
use tracing::info;
|
|
|
|
use crate::ipc::LocalStream;
|
|
use crate::protocol::endpoint::{
|
|
EndpointClientHello, EndpointServerWelcome, BLOB_CODEC_V1, ENDPOINT_HELLO_KIND,
|
|
ENDPOINT_PROTOCOL_GENERATION, ENDPOINT_WELCOME_KIND, INPUT_CODEC_V1, SNAPSHOT_CODEC_V1,
|
|
SURFACE_CODEC_V1,
|
|
};
|
|
use crate::protocol::{
|
|
self, ClientMessage, RenderEncoding, ServerMessage, MAX_FRAME_SIZE, PROTOCOL_VERSION,
|
|
};
|
|
|
|
#[cfg(unix)]
|
|
use super::terminal_setup::is_ssh_session;
|
|
use super::{shell, ClientError};
|
|
|
|
/// Time to wait for the server's Welcome reply during the handshake.
|
|
///
|
|
/// A local client talks to an already-connected server, so 5s is plenty. The
|
|
/// remote bridge client (`herdr --remote`) sits behind a fresh per-attach ssh
|
|
/// connection whose cold-connect (TCP + key exchange + auth) happens inside this
|
|
/// window; on a high-latency link that easily exceeds 5s, so it gets a far
|
|
/// larger budget. See issue #753.
|
|
pub(super) const LOCAL_HANDSHAKE_READ_TIMEOUT: Duration = Duration::from_secs(5);
|
|
pub(super) const REMOTE_HANDSHAKE_READ_TIMEOUT: Duration = Duration::from_secs(60);
|
|
|
|
pub(super) fn is_remote_client_process() -> bool {
|
|
std::env::var(crate::remote::REMOTE_KEYBINDINGS_ENV_VAR).is_ok()
|
|
}
|
|
|
|
pub(super) fn client_shell_keybinding_source() -> shell::ClientShellKeybindingSource {
|
|
match std::env::var(crate::remote::REMOTE_KEYBINDINGS_ENV_VAR)
|
|
.ok()
|
|
.as_deref()
|
|
{
|
|
Some("server") => shell::ClientShellKeybindingSource::Endpoint,
|
|
Some(_) => shell::ClientShellKeybindingSource::RemoteLocal,
|
|
None => shell::ClientShellKeybindingSource::Local,
|
|
}
|
|
}
|
|
|
|
pub(super) fn handshake_read_timeout() -> Duration {
|
|
if is_remote_client_process() {
|
|
return REMOTE_HANDSHAKE_READ_TIMEOUT;
|
|
}
|
|
LOCAL_HANDSHAKE_READ_TIMEOUT
|
|
}
|
|
|
|
#[cfg(any(unix, test))]
|
|
pub(super) fn direct_graphics_profile_values(
|
|
term_program: &str,
|
|
term: &str,
|
|
kitty_window: bool,
|
|
blocked_transport: bool,
|
|
terminals: bool,
|
|
) -> bool {
|
|
let supported = term_program.eq_ignore_ascii_case("ghostty")
|
|
|| term_program.eq_ignore_ascii_case("wezterm")
|
|
|| matches!(term, "xterm-ghostty" | "xterm-kitty" | "xterm-wezterm")
|
|
|| kitty_window;
|
|
supported && !blocked_transport && terminals
|
|
}
|
|
|
|
/// Server-owned files require a shared filesystem; the host terminal profile alone
|
|
/// cannot establish that for a saved SSH endpoint.
|
|
fn direct_graphics_capability(
|
|
local_transport: bool,
|
|
exact_cell_size: bool,
|
|
cell_size: (u32, u32),
|
|
profile_allowed: bool,
|
|
) -> bool {
|
|
local_transport && exact_cell_size && cell_size.0 > 0 && cell_size.1 > 0 && profile_allowed
|
|
}
|
|
|
|
#[cfg(unix)]
|
|
fn direct_graphics_profile_allowed() -> bool {
|
|
let term_program = std::env::var("TERM_PROGRAM").unwrap_or_default();
|
|
let term = std::env::var("TERM").unwrap_or_default();
|
|
direct_graphics_profile_values(
|
|
&term_program,
|
|
&term,
|
|
std::env::var_os("KITTY_WINDOW_ID").is_some(),
|
|
is_remote_client_process()
|
|
|| is_ssh_session()
|
|
|| std::env::var_os("TMUX").is_some()
|
|
|| std::env::var_os("STY").is_some(),
|
|
io::stdin().is_terminal() && io::stdout().is_terminal(),
|
|
)
|
|
}
|
|
|
|
#[cfg(not(unix))]
|
|
fn direct_graphics_profile_allowed() -> bool {
|
|
false
|
|
}
|
|
|
|
fn read_handshake_response(
|
|
stream: &mut LocalStream,
|
|
read_timeout: Duration,
|
|
stopped: &AtomicBool,
|
|
) -> Result<ServerMessage, ClientError> {
|
|
let deadline = Instant::now() + read_timeout;
|
|
crate::ipc::set_local_stream_polling(stream, true).map_err(ClientError::ConnectionFailed)?;
|
|
let response = protocol::read_message(
|
|
&mut super::transport::EndpointReader {
|
|
stream,
|
|
stopped,
|
|
deadline: Some(deadline),
|
|
},
|
|
MAX_FRAME_SIZE,
|
|
);
|
|
let restored = crate::ipc::set_local_stream_polling(stream, false);
|
|
let response = response?;
|
|
restored.map_err(ClientError::ConnectionFailed)?;
|
|
Ok(response)
|
|
}
|
|
|
|
#[derive(Debug)]
|
|
pub(super) struct HandshakeResult {
|
|
pub(super) encoding: RenderEncoding,
|
|
pub(super) endpoint_methods: Option<Vec<String>>,
|
|
pub(super) endpoint_capabilities: Option<Vec<String>>,
|
|
}
|
|
|
|
pub(crate) fn probe_endpoint_negotiation(
|
|
stream: &mut LocalStream,
|
|
) -> io::Result<super::endpoint::EndpointNegotiation> {
|
|
let handshake = do_handshake(
|
|
stream,
|
|
80,
|
|
24,
|
|
0,
|
|
0,
|
|
HandshakePixelCapabilities {
|
|
exact_geometry: false,
|
|
sgr_mouse: false,
|
|
},
|
|
Some(crate::protocol::ClientSurfaceSize { cols: 80, rows: 24 }),
|
|
false,
|
|
false,
|
|
false,
|
|
false, // This probe uses an SSH bridge, not a shared filesystem.
|
|
&AtomicBool::new(false),
|
|
)
|
|
.map_err(io::Error::other)?;
|
|
Ok(super::endpoint::EndpointNegotiation::new(
|
|
handshake.endpoint_methods.unwrap_or_default(),
|
|
handshake.endpoint_capabilities.unwrap_or_default(),
|
|
))
|
|
}
|
|
|
|
pub(super) struct HandshakePixelCapabilities {
|
|
pub(super) exact_geometry: bool,
|
|
pub(super) sgr_mouse: bool,
|
|
}
|
|
|
|
/// Performs the client→server handshake.
|
|
///
|
|
/// Direct terminal clients retain the same-install private protocol. Client-owned
|
|
/// shells use the stable endpoint generation and negotiate whole codecs without
|
|
/// comparing Herdr build versions. `local_transport` means the endpoint shares the
|
|
/// client's filesystem, not merely that its bridge exposes a local socket.
|
|
// Cancellation extends the existing handshake call; regrouping unrelated display options
|
|
// would broaden this lifecycle fix across all client entry points.
|
|
#[allow(clippy::too_many_arguments)]
|
|
pub(super) fn do_handshake(
|
|
stream: &mut LocalStream,
|
|
cols: u16,
|
|
rows: u16,
|
|
cell_width_px: u32,
|
|
cell_height_px: u32,
|
|
pixel_capabilities: HandshakePixelCapabilities,
|
|
shell_surface_size: Option<crate::protocol::ClientSurfaceSize>,
|
|
endpoint_keybindings: bool,
|
|
mouse_capture: bool,
|
|
surface_active: bool,
|
|
local_transport: bool,
|
|
stopped: &AtomicBool,
|
|
) -> Result<HandshakeResult, ClientError> {
|
|
stream
|
|
.set_nonblocking(false)
|
|
.map_err(ClientError::ConnectionFailed)?;
|
|
|
|
let endpoint_shell = shell_surface_size.is_some();
|
|
let hello = if let Some(surface_size) = shell_surface_size {
|
|
let hello = EndpointClientHello {
|
|
generation: ENDPOINT_PROTOCOL_GENERATION,
|
|
cell_width_px,
|
|
cell_height_px,
|
|
surface_size,
|
|
pixel_mouse: pixel_capabilities.exact_geometry && pixel_capabilities.sgr_mouse,
|
|
direct_graphics: direct_graphics_capability(
|
|
local_transport,
|
|
pixel_capabilities.exact_geometry,
|
|
(cell_width_px, cell_height_px),
|
|
direct_graphics_profile_allowed(),
|
|
),
|
|
endpoint_keybindings,
|
|
mouse_capture,
|
|
surface_active,
|
|
surface_reuse: true,
|
|
surface_delta: true,
|
|
surface_scroll: true,
|
|
snapshot_codecs: vec![SNAPSHOT_CODEC_V1.into()],
|
|
surface_codecs: vec![SURFACE_CODEC_V1.into()],
|
|
input_codecs: vec![INPUT_CODEC_V1.into()],
|
|
blob_codecs: vec![BLOB_CODEC_V1.into()],
|
|
};
|
|
ClientMessage::EndpointControl {
|
|
kind: ENDPOINT_HELLO_KIND.into(),
|
|
data: serde_json::to_string(&hello).map_err(|error| {
|
|
ClientError::ConnectionFailed(io::Error::new(io::ErrorKind::InvalidData, error))
|
|
})?,
|
|
}
|
|
} else {
|
|
ClientMessage::TerminalHello {
|
|
version: PROTOCOL_VERSION,
|
|
cols,
|
|
rows,
|
|
cell_width_px,
|
|
cell_height_px,
|
|
pixel_mouse: pixel_capabilities.exact_geometry && pixel_capabilities.sgr_mouse,
|
|
}
|
|
};
|
|
protocol::write_message(stream, &hello)
|
|
.map_err(|e| ClientError::ConnectionFailed(io::Error::other(e.to_string())))?;
|
|
|
|
let read_timeout = if endpoint_shell && !surface_active {
|
|
REMOTE_HANDSHAKE_READ_TIMEOUT
|
|
} else {
|
|
handshake_read_timeout()
|
|
};
|
|
let welcome = read_handshake_response(stream, read_timeout, stopped)?;
|
|
|
|
if endpoint_shell {
|
|
let ServerMessage::EndpointControl { kind, data } = welcome else {
|
|
return Err(ClientError::Protocol(protocol::FramingError::Io(
|
|
io::Error::new(
|
|
io::ErrorKind::InvalidData,
|
|
"server does not support the stable Herdr endpoint protocol; update this machine",
|
|
),
|
|
)));
|
|
};
|
|
if kind != ENDPOINT_WELCOME_KIND {
|
|
return Err(ClientError::Protocol(protocol::FramingError::Io(
|
|
io::Error::new(io::ErrorKind::InvalidData, "expected endpoint welcome"),
|
|
)));
|
|
}
|
|
let welcome: EndpointServerWelcome = serde_json::from_str(&data).map_err(|error| {
|
|
ClientError::Protocol(protocol::FramingError::Io(io::Error::new(
|
|
io::ErrorKind::InvalidData,
|
|
format!("invalid endpoint welcome: {error}"),
|
|
)))
|
|
})?;
|
|
if let Some(error) = welcome.error {
|
|
return Err(ClientError::HandshakeRejected {
|
|
version: welcome.generation,
|
|
error: error.message,
|
|
});
|
|
}
|
|
if welcome.generation != ENDPOINT_PROTOCOL_GENERATION
|
|
|| welcome.snapshot_codec != SNAPSHOT_CODEC_V1
|
|
|| welcome.surface_codec != SURFACE_CODEC_V1
|
|
|| welcome.input_codec != INPUT_CODEC_V1
|
|
|| welcome.blob_codec != BLOB_CODEC_V1
|
|
{
|
|
return Err(ClientError::HandshakeRejected {
|
|
version: welcome.generation,
|
|
error: "server has no compatible endpoint core; update this machine".into(),
|
|
});
|
|
}
|
|
info!(
|
|
generation = welcome.generation,
|
|
server_version = %welcome.server_version,
|
|
"endpoint handshake succeeded"
|
|
);
|
|
return Ok(HandshakeResult {
|
|
encoding: RenderEncoding::SemanticFrame,
|
|
endpoint_methods: Some(welcome.methods),
|
|
endpoint_capabilities: Some(welcome.capabilities),
|
|
});
|
|
}
|
|
|
|
match welcome {
|
|
ServerMessage::Welcome {
|
|
version,
|
|
encoding,
|
|
error,
|
|
} => {
|
|
if let Some(error) = error {
|
|
return Err(ClientError::HandshakeRejected { version, error });
|
|
}
|
|
info!(version, ?encoding, "handshake succeeded");
|
|
Ok(HandshakeResult {
|
|
encoding,
|
|
endpoint_methods: None,
|
|
endpoint_capabilities: None,
|
|
})
|
|
}
|
|
_ => Err(ClientError::Protocol(protocol::FramingError::Io(
|
|
io::Error::new(io::ErrorKind::InvalidData, "expected Welcome message"),
|
|
))),
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
fn stream_pair() -> (LocalStream, LocalStream) {
|
|
use interprocess::local_socket::traits::Listener as _;
|
|
let path = std::env::temp_dir().join(format!(
|
|
"hhs-{}-{:x}.sock",
|
|
std::process::id(),
|
|
std::time::SystemTime::now()
|
|
.duration_since(std::time::UNIX_EPOCH)
|
|
.unwrap()
|
|
.as_nanos()
|
|
));
|
|
let listener = crate::ipc::bind_private_local_listener(&path).unwrap();
|
|
let client = crate::ipc::connect_local_stream(&path).unwrap();
|
|
let server = listener.accept().unwrap();
|
|
std::fs::remove_file(path).unwrap();
|
|
(client, server)
|
|
}
|
|
|
|
#[test]
|
|
fn handshake_response_deadline_bounds_silent_and_partial_replies() {
|
|
use std::io::Write as _;
|
|
use std::time::Instant;
|
|
let mut outcomes = Vec::new();
|
|
for (prefix, drip) in [
|
|
(vec![], false),
|
|
(vec![100, 0], false),
|
|
(vec![100, 0, 0, 0, 1, 2], false),
|
|
(vec![100, 0, 0, 0], true),
|
|
] {
|
|
let (mut client, mut server) = stream_pair();
|
|
let (release, held) = std::sync::mpsc::channel();
|
|
let peer = std::thread::spawn(move || {
|
|
server.write_all(&prefix).unwrap();
|
|
if drip {
|
|
// Each fragment arrives sooner than the read budget; the total must stay bounded.
|
|
for _ in 0..12 {
|
|
if held.recv_timeout(Duration::from_millis(40)).is_ok() {
|
|
return;
|
|
}
|
|
if server.write_all(&[1]).is_err() {
|
|
return;
|
|
}
|
|
}
|
|
} else {
|
|
// Bound the reproduction even when the reader ignores its deadline.
|
|
let _ = held.recv_timeout(Duration::from_millis(500));
|
|
}
|
|
});
|
|
let start = Instant::now();
|
|
let result = read_handshake_response(
|
|
&mut client,
|
|
Duration::from_millis(100),
|
|
&AtomicBool::new(false),
|
|
);
|
|
let elapsed = start.elapsed();
|
|
let _ = release.send(());
|
|
peer.join().unwrap();
|
|
outcomes.push((elapsed, result));
|
|
}
|
|
for (elapsed, result) in outcomes {
|
|
assert!(
|
|
matches!(result, Err(ClientError::ConnectionLost(ref error)) if error.kind() == io::ErrorKind::TimedOut),
|
|
"{elapsed:?}: {result:?}"
|
|
);
|
|
assert!(elapsed < Duration::from_millis(400), "{elapsed:?}");
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn handshake_preserves_welcome_rejection_close_and_frame_limit() {
|
|
use std::io::Write as _;
|
|
for endpoint_shell in [false, true] {
|
|
for outcome in ["welcome", "rejected", "closed", "oversized"] {
|
|
let (mut client, mut server) = stream_pair();
|
|
let peer = std::thread::spawn(move || {
|
|
let hello: ClientMessage =
|
|
protocol::read_message(&mut server, MAX_FRAME_SIZE).unwrap();
|
|
assert!(
|
|
matches!(hello, ClientMessage::EndpointControl { .. }) == endpoint_shell
|
|
);
|
|
match outcome {
|
|
"closed" => (),
|
|
"oversized" => server
|
|
.write_all(&((MAX_FRAME_SIZE + 1) as u32).to_le_bytes())
|
|
.unwrap(),
|
|
_ => {
|
|
let welcome = if endpoint_shell {
|
|
let welcome = if outcome == "rejected" {
|
|
EndpointServerWelcome::incompatible(
|
|
"denied",
|
|
"permission denied",
|
|
)
|
|
} else {
|
|
EndpointServerWelcome::compatible(vec!["pane.close".into()])
|
|
};
|
|
ServerMessage::EndpointControl {
|
|
kind: ENDPOINT_WELCOME_KIND.into(),
|
|
data: serde_json::to_string(&welcome).unwrap(),
|
|
}
|
|
} else {
|
|
ServerMessage::Welcome {
|
|
version: PROTOCOL_VERSION,
|
|
encoding: RenderEncoding::TerminalAnsi,
|
|
error: (outcome == "rejected")
|
|
.then(|| "permission denied".into()),
|
|
}
|
|
};
|
|
protocol::write_message(&mut server, &welcome).unwrap();
|
|
}
|
|
}
|
|
});
|
|
let response = do_handshake(
|
|
&mut client,
|
|
80,
|
|
24,
|
|
0,
|
|
0,
|
|
HandshakePixelCapabilities {
|
|
exact_geometry: false,
|
|
sgr_mouse: false,
|
|
},
|
|
endpoint_shell
|
|
.then_some(crate::protocol::ClientSurfaceSize { cols: 80, rows: 24 }),
|
|
false,
|
|
false,
|
|
true,
|
|
true,
|
|
&AtomicBool::new(false),
|
|
);
|
|
peer.join().unwrap();
|
|
match outcome {
|
|
"welcome" => {
|
|
let response = response.unwrap();
|
|
assert_eq!(
|
|
response.encoding,
|
|
if endpoint_shell {
|
|
RenderEncoding::SemanticFrame
|
|
} else {
|
|
RenderEncoding::TerminalAnsi
|
|
}
|
|
);
|
|
if endpoint_shell {
|
|
assert_eq!(response.endpoint_methods, Some(vec!["pane.close".into()]));
|
|
}
|
|
}
|
|
"rejected" => assert!(
|
|
matches!(response, Err(ClientError::HandshakeRejected { error, .. }) if error == "permission denied")
|
|
),
|
|
"closed" => assert!(
|
|
matches!(response, Err(ClientError::ConnectionLost(ref error)) if error.kind() == io::ErrorKind::UnexpectedEof)
|
|
),
|
|
"oversized" => assert!(
|
|
matches!(response, Err(ClientError::Protocol(protocol::FramingError::Oversized { claimed, max })) if claimed == MAX_FRAME_SIZE + 1 && max == MAX_FRAME_SIZE)
|
|
),
|
|
_ => unreachable!(),
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn direct_graphics_requires_local_transport_even_on_supported_host_terminal() {
|
|
let supported = direct_graphics_profile_values("ghostty", "", false, false, true);
|
|
assert!(supported);
|
|
assert!(direct_graphics_capability(true, true, (8, 16), supported));
|
|
assert!(!direct_graphics_capability(false, true, (8, 16), supported));
|
|
assert!(!direct_graphics_capability(true, false, (8, 16), supported));
|
|
assert!(!direct_graphics_capability(true, true, (0, 16), supported));
|
|
assert!(!direct_graphics_capability(true, true, (8, 0), supported));
|
|
assert!(!direct_graphics_capability(true, true, (8, 16), false));
|
|
}
|
|
}
|