Files
herdr/src/client/handshake.rs
T

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));
}
}