mirror of
https://github.com/herdrdev/herdr.git
synced 2026-09-22 00:01:06 +00:00
@@ -101,6 +101,8 @@ Workspace, tab, pane IDs, and agent names are scoped to one server. Two machines
|
||||
|
||||
Saved profiles contain only an opaque ID, label, SSH target, explicit remote session, and enabled state. Herdr does not store passwords, private keys, agent tickets, or SSH control sockets in the catalog. Authentication stays with OpenSSH.
|
||||
|
||||
Herdr separately remembers each machine's remote OS and resolved executable path so repeated `--machine` commands can skip discovery. Existing profiles learn missing information on first use. This cache is optional: missing, invalid, or unwritable cache files do not prevent commands from working. Commands still check live server compatibility. If a cached executable is missing or no longer supports API forwarding, Herdr rediscovers it during the initial read-only check; it never automatically repeats a command that may already have changed remote state.
|
||||
|
||||
The client and server negotiate compatibility rather than requiring identical versions. Saved-machine connections additionally need the server's `surface_interest` and `health_check` capabilities. Older servers without those capabilities show Attention until explicitly updated, even if a standalone attach works. Other missing server methods disable only their corresponding actions.
|
||||
|
||||
Updating a compatible client does not replace the running remote server or stop its agents. When you need new server-side behavior, update that server explicitly. Normal replacement asks before stopping the server and its pane processes.
|
||||
|
||||
+90
-7
@@ -1,7 +1,7 @@
|
||||
use std::fmt;
|
||||
use std::io::{self, BufRead, BufReader, Write};
|
||||
use std::io::{self, BufRead, BufReader, Read, Write};
|
||||
use std::path::PathBuf;
|
||||
use std::time::Duration;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use interprocess::local_socket::traits::Stream as _;
|
||||
use serde::de::DeserializeOwned;
|
||||
@@ -75,10 +75,37 @@ impl ApiClient {
|
||||
}
|
||||
|
||||
pub fn status(&self) -> Result<crate::api::RuntimeStatus, ApiClientError> {
|
||||
let response = self.request(Request {
|
||||
self.read_status(None)
|
||||
}
|
||||
|
||||
pub(crate) fn status_with_timeout(
|
||||
&self,
|
||||
timeout: Duration,
|
||||
) -> Result<crate::api::RuntimeStatus, ApiClientError> {
|
||||
self.read_status(Some(timeout))
|
||||
}
|
||||
|
||||
fn read_status(
|
||||
&self,
|
||||
timeout: Option<Duration>,
|
||||
) -> Result<crate::api::RuntimeStatus, ApiClientError> {
|
||||
let request = Request {
|
||||
id: "api-client:status".into(),
|
||||
method: Method::Ping(PingParams::default()),
|
||||
})?;
|
||||
};
|
||||
let response = match timeout {
|
||||
Some(timeout) => {
|
||||
let mut stream = self.connect()?;
|
||||
write_request(&mut stream, &request)?;
|
||||
crate::ipc::set_local_stream_polling(&mut stream, true)?;
|
||||
let mut reader = BufReader::new(DeadlineReader {
|
||||
stream: &mut stream,
|
||||
deadline: Instant::now() + timeout,
|
||||
});
|
||||
parse_response_value(read_json_line(&mut reader)?)?
|
||||
}
|
||||
None => self.request(request)?,
|
||||
};
|
||||
match response.result {
|
||||
ResponseResult::Pong {
|
||||
version,
|
||||
@@ -162,9 +189,37 @@ fn write_request(stream: &mut LocalStream, request: &Request) -> Result<(), ApiC
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn read_json_line<T: DeserializeOwned>(
|
||||
reader: &mut BufReader<LocalStream>,
|
||||
) -> Result<T, ApiClientError> {
|
||||
struct DeadlineReader<'a> {
|
||||
stream: &'a mut LocalStream,
|
||||
deadline: Instant,
|
||||
}
|
||||
|
||||
impl Read for DeadlineReader<'_> {
|
||||
fn read(&mut self, buffer: &mut [u8]) -> io::Result<usize> {
|
||||
if buffer.is_empty() {
|
||||
return Ok(0);
|
||||
}
|
||||
loop {
|
||||
if Instant::now() >= self.deadline {
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::TimedOut,
|
||||
"server status probe timed out",
|
||||
));
|
||||
}
|
||||
// Windows named pipes have no read timeout; peek-before-read keeps
|
||||
// both idle and partial responses subject to the same deadline.
|
||||
match crate::ipc::poll_local_stream_read_count(self.stream, buffer)? {
|
||||
crate::ipc::LocalStreamReadCount::Data(count) => return Ok(count),
|
||||
crate::ipc::LocalStreamReadCount::Closed => return Ok(0),
|
||||
crate::ipc::LocalStreamReadCount::Pending => {
|
||||
std::thread::sleep(Duration::from_millis(2))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn read_json_line<T: DeserializeOwned>(reader: &mut impl BufRead) -> Result<T, ApiClientError> {
|
||||
let mut line = String::new();
|
||||
let read = reader.read_line(&mut line)?;
|
||||
if read == 0 || line.trim().is_empty() {
|
||||
@@ -199,6 +254,34 @@ mod tests {
|
||||
assert!(client.socket_path().ends_with("sessions/work/herdr.sock"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn status_timeout_closes_a_stalled_probe() {
|
||||
use interprocess::local_socket::traits::Listener as _;
|
||||
let path =
|
||||
std::env::temp_dir().join(format!("herdr-status-timeout-{}.sock", std::process::id()));
|
||||
let listener = crate::ipc::bind_private_local_listener(&path).unwrap();
|
||||
let server = std::thread::spawn(move || {
|
||||
let stream = listener.accept().unwrap();
|
||||
let mut reader = BufReader::new(stream);
|
||||
let mut line = String::new();
|
||||
reader.read_line(&mut line).unwrap();
|
||||
assert_eq!(
|
||||
serde_json::from_str::<serde_json::Value>(&line).unwrap()["method"],
|
||||
"ping"
|
||||
);
|
||||
std::thread::sleep(Duration::from_millis(300));
|
||||
});
|
||||
let client = ApiClient::for_target(ConnectionTarget::SocketPath(path.clone()));
|
||||
let error = client
|
||||
.status_with_timeout(Duration::from_millis(100))
|
||||
.unwrap_err();
|
||||
assert!(
|
||||
matches!(error, ApiClientError::Io(error) if matches!(error.kind(), io::ErrorKind::TimedOut | io::ErrorKind::WouldBlock))
|
||||
);
|
||||
server.join().unwrap();
|
||||
std::fs::remove_file(path).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn socket_path_target_uses_explicit_path() {
|
||||
let path = PathBuf::from("/tmp/herdr-test.sock");
|
||||
|
||||
+1
-2
@@ -782,8 +782,7 @@ pub(super) fn send_request_unchecked(request: &Request) -> std::io::Result<serde
|
||||
}
|
||||
|
||||
fn ensure_server_protocol_compatible(client: &ApiClient, request_id: &str) -> std::io::Result<()> {
|
||||
let status = client
|
||||
.status()
|
||||
let status = target::server_status(client)
|
||||
.map_err(|err| map_server_not_running_or_io(err, request_id, client))?;
|
||||
let server_protocol = status
|
||||
.protocol
|
||||
|
||||
+28
-6
@@ -164,18 +164,21 @@ fn add(args: &[String]) -> std::io::Result<i32> {
|
||||
return Ok(2);
|
||||
}
|
||||
}
|
||||
if let Err(error) = crate::remote::prepare_saved_ssh(&target, &session) {
|
||||
eprintln!("error: {error}; machine was not saved");
|
||||
crate::remote::print_saved_ssh_error_hint(&error, &target);
|
||||
return Ok(1);
|
||||
}
|
||||
let metadata = match crate::remote::prepare_saved_ssh(&target, &session) {
|
||||
Ok(metadata) => metadata,
|
||||
Err(error) => {
|
||||
eprintln!("error: {error}; machine was not saved");
|
||||
crate::remote::print_saved_ssh_error_hint(&error, &target);
|
||||
return Ok(1);
|
||||
}
|
||||
};
|
||||
// Setup can wait for human approval. Do not overwrite catalog edits made meanwhile.
|
||||
let mut catalog = load_catalog().map_err(|error| {
|
||||
std::io::Error::other(format!(
|
||||
"remote prepared, but machine was not saved: {error}"
|
||||
))
|
||||
})?;
|
||||
let id = match catalog.add_ssh(label, target, session) {
|
||||
let id = match catalog.add_ssh(label, &target, &session) {
|
||||
Ok(id) => id,
|
||||
Err(error) => {
|
||||
eprintln!("error: {error}");
|
||||
@@ -187,6 +190,10 @@ fn add(args: &[String]) -> std::io::Result<i32> {
|
||||
"remote prepared, but machine was not saved: {error}"
|
||||
))
|
||||
})?;
|
||||
if let Some(metadata) = metadata {
|
||||
crate::client::endpoint::SshMetadataCache::new(id.as_str(), &target, &session)?
|
||||
.store(&metadata);
|
||||
}
|
||||
println!("Saved SSH machine {id}. Remote server is ready.");
|
||||
println!("Open Herdr clients connect automatically.");
|
||||
Ok(0)
|
||||
@@ -232,11 +239,26 @@ fn remove(args: &[String]) -> std::io::Result<i32> {
|
||||
};
|
||||
let mut catalog = load_catalog()?;
|
||||
let previous_selection = catalog.selected_profile.clone();
|
||||
let metadata_cache = catalog
|
||||
.ssh
|
||||
.iter()
|
||||
.find(|profile| profile.id == id)
|
||||
.map(|profile| {
|
||||
crate::client::endpoint::SshMetadataCache::new(
|
||||
id.as_str(),
|
||||
&profile.target,
|
||||
&profile.session,
|
||||
)
|
||||
})
|
||||
.transpose()?;
|
||||
if !catalog.remove_ssh(&id) {
|
||||
eprintln!("machine profile {id} was not found");
|
||||
return Ok(1);
|
||||
}
|
||||
store_catalog(&catalog)?;
|
||||
if let Some(cache) = metadata_cache {
|
||||
cache.invalidate();
|
||||
}
|
||||
if catalog.selected_profile != previous_selection {
|
||||
catalog.store_selection().map_err(std::io::Error::other)?;
|
||||
}
|
||||
|
||||
+1
-1
@@ -174,7 +174,7 @@ fn print_server_status_body(server: &ServerRuntimeStatus, indent: &str) {
|
||||
}
|
||||
|
||||
fn read_server_runtime_status() -> std::io::Result<ServerRuntimeStatus> {
|
||||
match super::target::api_client()?.status() {
|
||||
match super::target::server_status(&super::target::api_client()?) {
|
||||
Ok(status) => Ok(ServerRuntimeStatus::Running {
|
||||
version: status.version,
|
||||
protocol: status.protocol,
|
||||
|
||||
@@ -79,6 +79,7 @@ pub(super) fn api_client() -> io::Result<ApiClient> {
|
||||
target.profile.id.as_str(),
|
||||
&target.profile.target,
|
||||
&target.profile.session,
|
||||
true,
|
||||
)
|
||||
.map_err(|error| {
|
||||
io::Error::new(
|
||||
@@ -98,6 +99,51 @@ pub(super) fn api_client() -> io::Result<ApiClient> {
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) fn server_status(
|
||||
client: &ApiClient,
|
||||
) -> Result<crate::api::RuntimeStatus, crate::api::client::ApiClientError> {
|
||||
let probe = || {
|
||||
if is_remote() {
|
||||
client.status_with_timeout(std::time::Duration::from_secs(15))
|
||||
} else {
|
||||
client.status()
|
||||
}
|
||||
};
|
||||
let error = match probe() {
|
||||
Ok(status) => return Ok(status),
|
||||
Err(error) => error,
|
||||
};
|
||||
// Only this read-only probe may rediscover and retry. Requests that follow
|
||||
// the probe must never be replayed after an ambiguous SSH failure.
|
||||
TARGET.with(|target| {
|
||||
let mut target = target.borrow_mut();
|
||||
let Some(target) = target.as_mut() else {
|
||||
return Err(error);
|
||||
};
|
||||
let Some(bridge) = target.bridge.as_ref() else {
|
||||
return Err(error);
|
||||
};
|
||||
let Some(failure) = bridge.reported_failure() else {
|
||||
return Err(error);
|
||||
};
|
||||
if !bridge.used_cached_metadata
|
||||
|| !crate::remote::SavedSshApiBridge::stale_metadata_failure(&failure)
|
||||
{
|
||||
return Err(failure.into());
|
||||
}
|
||||
bridge.invalidate_metadata();
|
||||
target.bridge.take();
|
||||
target.bridge = Some(crate::remote::SavedSshApiBridge::start(
|
||||
target.profile.id.as_str(),
|
||||
&target.profile.target,
|
||||
&target.profile.session,
|
||||
false,
|
||||
)?);
|
||||
Ok(())
|
||||
})?;
|
||||
probe()
|
||||
}
|
||||
|
||||
pub(super) fn remote_error(error: io::Error) -> io::Error {
|
||||
TARGET.with(|target| {
|
||||
let target = target.borrow();
|
||||
|
||||
@@ -9,6 +9,7 @@ mod control;
|
||||
mod health;
|
||||
mod message_policy;
|
||||
mod registry;
|
||||
mod ssh_metadata;
|
||||
mod supervisor;
|
||||
mod writer;
|
||||
|
||||
@@ -17,6 +18,7 @@ pub(crate) use catalog::*;
|
||||
pub(crate) use control::*;
|
||||
pub(crate) use message_policy::*;
|
||||
pub(crate) use registry::*;
|
||||
pub(crate) use ssh_metadata::{SshMachineMetadata, SshMetadataCache};
|
||||
pub(crate) use supervisor::*;
|
||||
pub(crate) use writer::NativeEndpointTransport;
|
||||
|
||||
|
||||
@@ -331,7 +331,11 @@ fn load_selection_from_path(path: &Path) -> Result<Option<EndpointSelection>, St
|
||||
Ok(Some(selection))
|
||||
}
|
||||
|
||||
fn store_private_json(path: &Path, content: &[u8], description: &str) -> Result<(), String> {
|
||||
pub(super) fn store_private_json(
|
||||
path: &Path,
|
||||
content: &[u8],
|
||||
description: &str,
|
||||
) -> Result<(), String> {
|
||||
if content.len() as u64 > MAX_CATALOG_BYTES {
|
||||
return Err(format!("{description} exceeds the storage limit"));
|
||||
}
|
||||
|
||||
@@ -0,0 +1,221 @@
|
||||
use std::io::{self, Read as _};
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use super::ProfileId;
|
||||
|
||||
const MAX_METADATA_BYTES: u64 = 16 * 1024;
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub(crate) struct SshMachineMetadata {
|
||||
pub(crate) os: String,
|
||||
pub(crate) executable: String,
|
||||
}
|
||||
|
||||
impl SshMachineMetadata {
|
||||
pub(crate) fn is_valid(&self) -> bool {
|
||||
let path = &self.executable;
|
||||
if path.is_empty() || path.len() > 4096 || path.chars().any(char::is_control) {
|
||||
return false;
|
||||
}
|
||||
match self.os.as_str() {
|
||||
"linux" | "macos" => path.starts_with('/') && !path.ends_with("/mise/shims/herdr"),
|
||||
"windows" => {
|
||||
let bytes = path.as_bytes();
|
||||
path.starts_with(r"\\")
|
||||
|| (bytes.len() >= 3
|
||||
&& bytes[0].is_ascii_alphabetic()
|
||||
&& bytes[1] == b':'
|
||||
&& matches!(bytes[2], b'\\' | b'/'))
|
||||
}
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
struct StoredMetadata {
|
||||
version: u32,
|
||||
target: String,
|
||||
session: String,
|
||||
metadata: SshMachineMetadata,
|
||||
}
|
||||
|
||||
pub(crate) struct SshMetadataCache {
|
||||
path: PathBuf,
|
||||
target: String,
|
||||
session: String,
|
||||
}
|
||||
|
||||
impl SshMetadataCache {
|
||||
pub(crate) fn new(profile_id: &str, target: &str, session: &str) -> io::Result<Self> {
|
||||
let id = ProfileId::parse(profile_id)
|
||||
.map_err(|error| io::Error::new(io::ErrorKind::InvalidInput, error))?;
|
||||
Ok(Self {
|
||||
path: crate::config::state_dir()
|
||||
.join("client/ssh-metadata")
|
||||
.join(format!("{id}.json")),
|
||||
target: target.to_owned(),
|
||||
session: session.to_owned(),
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn load(&self) -> Option<SshMachineMetadata> {
|
||||
load_metadata(&self.path, &self.target, &self.session)
|
||||
}
|
||||
|
||||
pub(crate) fn store(&self, metadata: &SshMachineMetadata) {
|
||||
if !metadata.is_valid() {
|
||||
return;
|
||||
}
|
||||
let stored = StoredMetadata {
|
||||
version: 1,
|
||||
target: self.target.clone(),
|
||||
session: self.session.clone(),
|
||||
metadata: metadata.clone(),
|
||||
};
|
||||
let result = serde_json::to_vec(&stored)
|
||||
.map_err(|error| error.to_string())
|
||||
.and_then(|bytes| {
|
||||
super::catalog::store_private_json(&self.path, &bytes, "SSH metadata")
|
||||
});
|
||||
if let Err(error) = result {
|
||||
tracing::debug!(%error, "could not cache SSH machine metadata");
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn invalidate(&self) {
|
||||
if let Err(error) = std::fs::remove_file(&self.path) {
|
||||
if error.kind() != io::ErrorKind::NotFound {
|
||||
tracing::debug!(%error, "could not invalidate SSH machine metadata");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn load_metadata(path: &Path, target: &str, session: &str) -> Option<SshMachineMetadata> {
|
||||
let file_type = std::fs::symlink_metadata(path).ok()?.file_type();
|
||||
if !file_type.is_file() {
|
||||
return None;
|
||||
}
|
||||
let mut bytes = Vec::new();
|
||||
std::fs::File::open(path)
|
||||
.ok()?
|
||||
.take(MAX_METADATA_BYTES + 1)
|
||||
.read_to_end(&mut bytes)
|
||||
.ok()?;
|
||||
if bytes.len() as u64 > MAX_METADATA_BYTES {
|
||||
return None;
|
||||
}
|
||||
let stored: StoredMetadata = serde_json::from_slice(&bytes).ok()?;
|
||||
(stored.version == 1
|
||||
&& stored.target == target
|
||||
&& stored.session == session
|
||||
&& stored.metadata.is_valid())
|
||||
.then_some(stored.metadata)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn metadata_accepts_only_supported_platforms_and_absolute_paths() {
|
||||
for (os, path, valid) in [
|
||||
("linux", "/home/a b/herdr", true),
|
||||
("macos", "/opt/homebrew/bin/herdr", true),
|
||||
("windows", r"C:\Program Files\herdr.exe", true),
|
||||
("windows", r"\\server\share\herdr.exe", true),
|
||||
("windows", "herdr.exe", false),
|
||||
("linux", "$HOME/.local/bin/herdr", false),
|
||||
("linux", "/home/user/.local/share/mise/shims/herdr", false),
|
||||
("linux", "/bin/herdr\nmalformed", false),
|
||||
("unknown", "/bin/herdr", false),
|
||||
] {
|
||||
assert_eq!(
|
||||
SshMachineMetadata {
|
||||
os: os.into(),
|
||||
executable: path.into()
|
||||
}
|
||||
.is_valid(),
|
||||
valid,
|
||||
"{os}: {path}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn metadata_is_disposable_fingerprinted_and_independent_per_profile() {
|
||||
let root = std::env::temp_dir().join(format!("herdr-ssh-metadata-{}", std::process::id()));
|
||||
let first = SshMetadataCache {
|
||||
path: root.join("first.json"),
|
||||
target: "mac".into(),
|
||||
session: "fleet".into(),
|
||||
};
|
||||
let second = SshMetadataCache {
|
||||
path: root.join("second.json"),
|
||||
target: "mac".into(),
|
||||
session: "fleet".into(),
|
||||
};
|
||||
let metadata = SshMachineMetadata {
|
||||
os: "macos".into(),
|
||||
executable: "/some path/herdr".into(),
|
||||
};
|
||||
assert!(first.load().is_none());
|
||||
first.store(&metadata);
|
||||
second.store(&metadata);
|
||||
assert_eq!(first.load(), Some(metadata.clone()));
|
||||
assert!(load_metadata(&first.path, "different-host", "fleet").is_none());
|
||||
assert!(load_metadata(&first.path, "mac", "different-session").is_none());
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt as _;
|
||||
assert_eq!(
|
||||
std::fs::metadata(&first.path).unwrap().permissions().mode() & 0o777,
|
||||
0o600
|
||||
);
|
||||
}
|
||||
let mut stored: serde_json::Value =
|
||||
serde_json::from_slice(&std::fs::read(&first.path).unwrap()).unwrap();
|
||||
stored["future_field"] = true.into();
|
||||
std::fs::write(&first.path, serde_json::to_vec(&stored).unwrap()).unwrap();
|
||||
assert_eq!(first.load(), Some(metadata.clone()));
|
||||
stored["version"] = 2.into();
|
||||
std::fs::write(&first.path, serde_json::to_vec(&stored).unwrap()).unwrap();
|
||||
assert!(first.load().is_none());
|
||||
for bytes in [
|
||||
b"broken".to_vec(),
|
||||
vec![b' '; MAX_METADATA_BYTES as usize + 1],
|
||||
] {
|
||||
std::fs::write(&first.path, bytes).unwrap();
|
||||
assert!(first.load().is_none());
|
||||
}
|
||||
first.invalidate();
|
||||
assert_eq!(second.load(), Some(metadata));
|
||||
std::fs::remove_dir_all(root).unwrap();
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn metadata_does_not_follow_symlinks() {
|
||||
let root =
|
||||
std::env::temp_dir().join(format!("herdr-ssh-metadata-link-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&root).unwrap();
|
||||
let cache = SshMetadataCache {
|
||||
path: root.join("cache.json"),
|
||||
target: "mac".into(),
|
||||
session: "fleet".into(),
|
||||
};
|
||||
let other = root.join("other");
|
||||
std::fs::write(&other, "untouched").unwrap();
|
||||
std::os::unix::fs::symlink(&other, &cache.path).unwrap();
|
||||
assert!(cache.load().is_none());
|
||||
cache.store(&SshMachineMetadata {
|
||||
os: "macos".into(),
|
||||
executable: "/bin/herdr".into(),
|
||||
});
|
||||
assert_eq!(std::fs::read_to_string(&other).unwrap(), "untouched");
|
||||
std::fs::remove_dir_all(root).unwrap();
|
||||
}
|
||||
}
|
||||
+175
-10
@@ -86,7 +86,10 @@ pub(crate) fn run_remote(remote: RemoteLaunch) -> io::Result<()> {
|
||||
run_client_process(&local_socket, &reattach_command, remote.keybindings)
|
||||
}
|
||||
|
||||
pub(crate) fn prepare_saved_ssh(target: &str, session_name: &str) -> io::Result<()> {
|
||||
pub(crate) fn prepare_saved_ssh(
|
||||
target: &str,
|
||||
session_name: &str,
|
||||
) -> io::Result<Option<crate::client::endpoint::SshMachineMetadata>> {
|
||||
super::validate_remote_target(target)
|
||||
.map_err(|error| io::Error::new(io::ErrorKind::InvalidInput, error))?;
|
||||
crate::session::validate_name(session_name)
|
||||
@@ -135,7 +138,13 @@ pub(crate) fn prepare_saved_ssh(target: &str, session_name: &str) -> io::Result<
|
||||
)
|
||||
.is_none() =>
|
||||
{
|
||||
Ok(())
|
||||
Ok(prepared.remote_herdr.machine_metadata().or_else(|| {
|
||||
discover_remote_api_metadata(&ssh, session_name)
|
||||
.inspect_err(
|
||||
|error| tracing::debug!(%error, "could not capture SSH setup metadata"),
|
||||
)
|
||||
.ok()
|
||||
}))
|
||||
}
|
||||
_ => Err(io::Error::other(
|
||||
"remote server is not ready for saved machines",
|
||||
@@ -318,11 +327,21 @@ impl RemoteExecutable {
|
||||
pub(super) struct RemoteHerdr {
|
||||
install_suffix: String,
|
||||
executable: RemoteExecutable,
|
||||
resolved_executable: Option<String>,
|
||||
platform: RemotePlatform,
|
||||
bridge_idle_timeout: bool,
|
||||
}
|
||||
|
||||
impl RemoteHerdr {
|
||||
pub(super) fn machine_metadata(&self) -> Option<crate::client::endpoint::SshMachineMetadata> {
|
||||
let executable = self.resolved_executable.clone()?;
|
||||
let metadata = crate::client::endpoint::SshMachineMetadata {
|
||||
os: self.platform.os.to_owned(),
|
||||
executable,
|
||||
};
|
||||
metadata.is_valid().then_some(metadata)
|
||||
}
|
||||
|
||||
fn for_platform(platform: RemotePlatform) -> Self {
|
||||
let (install_suffix, executable) = if platform.is_windows() {
|
||||
(
|
||||
@@ -337,17 +356,20 @@ impl RemoteHerdr {
|
||||
Self {
|
||||
install_suffix,
|
||||
executable,
|
||||
resolved_executable: None,
|
||||
platform,
|
||||
bridge_idle_timeout: false,
|
||||
}
|
||||
}
|
||||
|
||||
fn with_shell_path(mut self, shell_path: String) -> Self {
|
||||
self.executable = RemoteExecutable::PosixShellPath(shell_path);
|
||||
fn with_posix_path(mut self, path: &str) -> Self {
|
||||
self.executable = RemoteExecutable::PosixShellPath(shell_quote(path));
|
||||
self.resolved_executable = Some(path.to_owned());
|
||||
self
|
||||
}
|
||||
|
||||
fn with_windows_path(mut self, path: String) -> Self {
|
||||
self.resolved_executable = Some(path.clone());
|
||||
self.executable = RemoteExecutable::WindowsPath(path);
|
||||
self
|
||||
}
|
||||
@@ -364,6 +386,10 @@ fn windows_powershell_application_script(path: &str, args: &[&str]) -> String {
|
||||
}
|
||||
|
||||
fn windows_powershell_streaming_application_command(path: &str, args: &[&str]) -> String {
|
||||
windows_powershell_script_command(&windows_powershell_streaming_application_script(path, args))
|
||||
}
|
||||
|
||||
fn windows_powershell_streaming_application_script(path: &str, args: &[&str]) -> String {
|
||||
let command_line = args
|
||||
.iter()
|
||||
.map(|arg| crate::platform::quote_windows_command_line_arg(arg))
|
||||
@@ -371,11 +397,11 @@ fn windows_powershell_streaming_application_command(path: &str, args: &[&str]) -
|
||||
.join(" ");
|
||||
// Start-Process -Wait waits for descendants, including a cold-started server.
|
||||
// Retain the handle so Windows PowerShell 5.1 keeps the application's exit code.
|
||||
windows_powershell_script_command(&format!(
|
||||
format!(
|
||||
"$process = Start-Process -FilePath {} -ArgumentList {} -NoNewWindow -PassThru -ErrorAction Stop; $null = $process.Handle; $process.WaitForExit(); exit $process.ExitCode",
|
||||
crate::platform::quote_powershell_arg(path),
|
||||
crate::platform::quote_powershell_arg(&command_line),
|
||||
))
|
||||
)
|
||||
}
|
||||
|
||||
fn posix_remote_output_command(command: &str) -> String {
|
||||
@@ -1248,11 +1274,29 @@ fn prepare_windows_remote_herdr(
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) fn find_installed_remote_api_herdr(
|
||||
pub(super) fn discover_remote_api_metadata(
|
||||
ssh: &RemoteSsh,
|
||||
session: &str,
|
||||
) -> io::Result<RemoteHerdr> {
|
||||
) -> io::Result<crate::client::endpoint::SshMachineMetadata> {
|
||||
let platform = detect_remote_platform(ssh)?;
|
||||
if !platform.is_windows() {
|
||||
let output =
|
||||
ssh.framed_user_shell_output(&posix_remote_api_discovery_command(&platform, session))?;
|
||||
if !output.status.success() {
|
||||
return Err(command_failed("remote binary discovery failed", &output));
|
||||
}
|
||||
let metadata = crate::client::endpoint::SshMachineMetadata {
|
||||
os: platform.os.to_owned(),
|
||||
executable: String::from_utf8_lossy(&output.stdout).trim().to_owned(),
|
||||
};
|
||||
if !metadata.is_valid() {
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::InvalidData,
|
||||
"invalid remote Herdr executable path",
|
||||
));
|
||||
}
|
||||
return Ok(metadata);
|
||||
}
|
||||
let remote_herdr = RemoteHerdr::for_platform(platform);
|
||||
let candidates = remote_binary_candidates(ssh, &remote_herdr)?;
|
||||
for candidate in candidates {
|
||||
@@ -1264,7 +1308,10 @@ pub(super) fn find_installed_remote_api_herdr(
|
||||
if probe.status.success()
|
||||
&& String::from_utf8_lossy(&probe.stdout).trim() == "herdr-api-bridge-v1"
|
||||
{
|
||||
return Ok(candidate);
|
||||
return Ok(crate::client::endpoint::SshMachineMetadata {
|
||||
os: "windows".into(),
|
||||
executable: candidate.executable.display().to_owned(),
|
||||
});
|
||||
}
|
||||
}
|
||||
Err(io::Error::new(
|
||||
@@ -1511,7 +1558,7 @@ fn remote_herdr_from_path(remote_herdr: &RemoteHerdr, path: &str) -> Option<Remo
|
||||
if is_mise_shim_path(path) {
|
||||
return None;
|
||||
}
|
||||
Some(remote_herdr.clone().with_shell_path(shell_quote(path)))
|
||||
Some(remote_herdr.clone().with_posix_path(path))
|
||||
}
|
||||
|
||||
fn is_mise_shim_path(path: &str) -> bool {
|
||||
@@ -2383,6 +2430,67 @@ fn confirm_remote_install(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn posix_remote_api_discovery_command(platform: &RemotePlatform, session: &str) -> String {
|
||||
let script = format!(
|
||||
r#"set -f
|
||||
candidates=$(
|
||||
command -v herdr
|
||||
{discovery}
|
||||
)
|
||||
IFS='
|
||||
'
|
||||
for candidate in $candidates; do
|
||||
case "$candidate" in
|
||||
*/mise/shims/herdr) continue ;;
|
||||
/*) ;;
|
||||
*) continue ;;
|
||||
esac
|
||||
[ -x "$candidate" ] || continue
|
||||
if capability=$("$candidate" --session {session} remote-api-bridge --check </dev/null 2>/dev/null) && [ "$capability" = herdr-api-bridge-v1 ]; then
|
||||
printf '%s\n' "$candidate"
|
||||
exit 0
|
||||
fi
|
||||
done
|
||||
printf '%s\n' 'remote Herdr does not support machine API forwarding; update Herdr on this machine' >&2
|
||||
exit 2"#,
|
||||
discovery = known_remote_binary_candidate_script(platform),
|
||||
session = shell_quote(session),
|
||||
);
|
||||
format!(
|
||||
"/bin/sh -c {}",
|
||||
shell_quote(&posix_remote_output_command(&script))
|
||||
)
|
||||
}
|
||||
|
||||
pub(super) const STALE_API_METADATA: &str = "herdr-machine-metadata-stale-v1";
|
||||
|
||||
pub(super) fn cached_remote_api_command(
|
||||
metadata: &crate::client::endpoint::SshMachineMetadata,
|
||||
session: &str,
|
||||
) -> String {
|
||||
if metadata.os == "windows" {
|
||||
let path = crate::platform::quote_powershell_arg(&metadata.executable);
|
||||
let session_arg = crate::platform::quote_powershell_arg(session);
|
||||
let probe = format!(
|
||||
"$capability = & {path} --session {session_arg} remote-api-bridge --check 2>$null; if ($LASTEXITCODE -ne 0 -or $capability -ne 'herdr-api-bridge-v1') {{ [Console]::Error.WriteLine('{STALE_API_METADATA}'); exit 78 }}; "
|
||||
);
|
||||
return windows_powershell_script_command(&format!(
|
||||
"{probe}{}",
|
||||
windows_powershell_streaming_application_script(
|
||||
&metadata.executable,
|
||||
&["--session", session, "remote-api-bridge"]
|
||||
),
|
||||
));
|
||||
}
|
||||
let path = shell_quote(&metadata.executable);
|
||||
let session = shell_quote(session);
|
||||
let script = format!(
|
||||
"if capability=$({path} --session {session} remote-api-bridge --check </dev/null 2>/dev/null) && [ \"$capability\" = herdr-api-bridge-v1 ]; then\n{}\nelse\n printf '%s\\n' '{STALE_API_METADATA}' >&2\n exit 78\nfi",
|
||||
posix_remote_output_command(&format!("exec {path} --session {session} remote-api-bridge")),
|
||||
);
|
||||
format!("/bin/sh -c {}", shell_quote(&script))
|
||||
}
|
||||
|
||||
pub(super) fn remote_api_bridge_command(
|
||||
remote_herdr: &RemoteHerdr,
|
||||
session_name: &str,
|
||||
@@ -4069,6 +4177,63 @@ mod tests {
|
||||
assert_eq!(status.and_then(|status| status.code()), Some(23));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn machine_metadata_keeps_raw_resolved_paths_not_shell_expressions() {
|
||||
let remote = RemoteHerdr::for_platform(RemotePlatform {
|
||||
os: "linux",
|
||||
arch: "x86_64",
|
||||
});
|
||||
assert!(remote.machine_metadata().is_none());
|
||||
let path = "/home/user's files/$literal/herdr";
|
||||
let resolved = remote.with_posix_path(path);
|
||||
assert_eq!(resolved.machine_metadata().unwrap().executable, path);
|
||||
assert_eq!(resolved.executable.display(), shell_quote(path));
|
||||
let remote = RemoteHerdr::for_platform(RemotePlatform {
|
||||
os: "windows",
|
||||
arch: "x86_64",
|
||||
});
|
||||
assert!(remote.machine_metadata().is_none());
|
||||
let path = r"C:\Users\A B\herdr.exe";
|
||||
assert_eq!(
|
||||
remote
|
||||
.with_windows_path(path.into())
|
||||
.machine_metadata()
|
||||
.unwrap()
|
||||
.executable,
|
||||
path
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cached_windows_api_command_checks_before_starting_the_stream() {
|
||||
let path = r"C:\Users\A'B\herdr.exe";
|
||||
let command = cached_remote_api_command(
|
||||
&crate::client::endpoint::SshMachineMetadata {
|
||||
os: "windows".into(),
|
||||
executable: path.into(),
|
||||
},
|
||||
"fleet",
|
||||
);
|
||||
let encoded = command.split_whitespace().last().unwrap();
|
||||
let bytes = base64::engine::general_purpose::STANDARD
|
||||
.decode(encoded)
|
||||
.unwrap();
|
||||
let words = bytes
|
||||
.chunks_exact(2)
|
||||
.map(|chunk| u16::from_le_bytes([chunk[0], chunk[1]]))
|
||||
.collect::<Vec<_>>();
|
||||
let script = String::from_utf16(&words).unwrap();
|
||||
assert!(script.contains(&crate::platform::quote_powershell_arg(path)));
|
||||
assert!(script.contains(STALE_API_METADATA));
|
||||
assert!(
|
||||
script.find("remote-api-bridge --check").unwrap()
|
||||
< script.find("Start-Process").unwrap()
|
||||
);
|
||||
assert!(script.contains("$LASTEXITCODE -ne 0"));
|
||||
assert!(script.contains("-NoNewWindow -PassThru"));
|
||||
assert!(script.contains("--session fleet remote-api-bridge"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn windows_remote_commands_use_one_encoded_powershell_grammar() {
|
||||
let executable = RemoteExecutable::WindowsPath("herdr.exe".to_string());
|
||||
|
||||
+42
-4
@@ -19,6 +19,7 @@ pub(crate) fn connect_saved_ssh(
|
||||
) -> io::Result<SavedSshStream> {
|
||||
let ssh = validated_saved_ssh(profile_id, target, session)?;
|
||||
let remote_herdr = find_installed_remote_herdr(&ssh)?;
|
||||
let metadata = remote_herdr.machine_metadata();
|
||||
let path = saved_bridge_path(profile_id);
|
||||
let bridge = SshStdioBridge::start(
|
||||
target.to_owned(),
|
||||
@@ -29,6 +30,10 @@ pub(crate) fn connect_saved_ssh(
|
||||
true,
|
||||
)?;
|
||||
let stream = crate::ipc::connect_local_stream(&path)?;
|
||||
if let Some(metadata) = metadata {
|
||||
crate::client::endpoint::SshMetadataCache::new(profile_id, target, session)?
|
||||
.store(&metadata);
|
||||
}
|
||||
Ok(SavedSshStream {
|
||||
stream,
|
||||
bridge: SavedSshBridge { _bridge: bridge },
|
||||
@@ -38,13 +43,31 @@ pub(crate) fn connect_saved_ssh(
|
||||
pub(crate) struct SavedSshApiBridge {
|
||||
path: PathBuf,
|
||||
bridge: SshStdioBridge,
|
||||
metadata_cache: crate::client::endpoint::SshMetadataCache,
|
||||
pub(crate) used_cached_metadata: bool,
|
||||
}
|
||||
|
||||
impl SavedSshApiBridge {
|
||||
pub(crate) fn start(profile_id: &str, target: &str, session: &str) -> io::Result<Self> {
|
||||
pub(crate) fn start(
|
||||
profile_id: &str,
|
||||
target: &str,
|
||||
session: &str,
|
||||
use_cached_metadata: bool,
|
||||
) -> io::Result<Self> {
|
||||
let ssh = validated_saved_ssh(profile_id, target, session)?;
|
||||
let remote_herdr = super::attach::find_installed_remote_api_herdr(&ssh, session)?;
|
||||
let command = super::attach::remote_api_bridge_command(&remote_herdr, session, false);
|
||||
let metadata_cache =
|
||||
crate::client::endpoint::SshMetadataCache::new(profile_id, target, session)?;
|
||||
let cached = use_cached_metadata.then(|| metadata_cache.load()).flatten();
|
||||
let used_cached_metadata = cached.is_some();
|
||||
let metadata = match cached {
|
||||
Some(metadata) => metadata,
|
||||
None => {
|
||||
let metadata = super::attach::discover_remote_api_metadata(&ssh, session)?;
|
||||
metadata_cache.store(&metadata);
|
||||
metadata
|
||||
}
|
||||
};
|
||||
let command = super::attach::cached_remote_api_command(&metadata, session);
|
||||
let path = crate::platform::remote_bridge_endpoint_path(
|
||||
&format!("herdr-api-ssh-{}-{profile_id}.sock", std::process::id()),
|
||||
&format!(
|
||||
@@ -60,7 +83,12 @@ impl SavedSshApiBridge {
|
||||
ssh.options(),
|
||||
true,
|
||||
)?;
|
||||
Ok(Self { path, bridge })
|
||||
Ok(Self {
|
||||
path,
|
||||
bridge,
|
||||
metadata_cache,
|
||||
used_cached_metadata,
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn socket_path(&self) -> &std::path::Path {
|
||||
@@ -70,6 +98,16 @@ impl SavedSshApiBridge {
|
||||
pub(crate) fn reported_failure(&self) -> Option<io::Error> {
|
||||
self.bridge.reported_failure()
|
||||
}
|
||||
|
||||
pub(crate) fn invalidate_metadata(&self) {
|
||||
self.metadata_cache.invalidate();
|
||||
}
|
||||
|
||||
pub(crate) fn stale_metadata_failure(error: &io::Error) -> bool {
|
||||
error
|
||||
.to_string()
|
||||
.contains(super::attach::STALE_API_METADATA)
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn saved_ssh_bootstrap_command(target: &str, session: &str) -> String {
|
||||
|
||||
+258
-8
@@ -13,24 +13,21 @@ use serde_json::{json, Value};
|
||||
|
||||
const PROFILE_ID: &str = "0123456789abcdef0123456789abcdef";
|
||||
const SSH: &str = r#"#!/bin/sh
|
||||
printf 'ssh\n' >> "$TEST_ROOT/ssh-calls"
|
||||
for arg do
|
||||
last=$arg
|
||||
printf '%s\n' "$arg" >> "$TEST_ROOT/ssh-args"
|
||||
done
|
||||
case "$last" in
|
||||
*'command -v herdr') printf 'login banner\nherdr-remote-output-ready:1\n%s\n' "$TEST_REMOTE_HERDR" ;;
|
||||
*'remote-api-bridge --check')
|
||||
if [ "$TEST_MODE" = old ]; then exit 2; fi
|
||||
exec /bin/sh -c "$last" ;;
|
||||
*'remote-api-bridge')
|
||||
'/bin/sh -c '*)
|
||||
if [ "$TEST_MODE" = offline ]; then echo 'test remote connection failed' >&2; exit 255; fi
|
||||
exec /bin/sh -c "$last" ;;
|
||||
printf 'login banner\n'
|
||||
PATH="$TEST_ROOT/remote bin:/usr/bin:/bin" exec /bin/sh -c "$last" ;;
|
||||
'/bin/sh -s')
|
||||
script=$(cat)
|
||||
printf 'login banner\nherdr-remote-output-ready:1\n'
|
||||
case "$script" in
|
||||
*'uname -s'*) uname -s; uname -m ;;
|
||||
*'version='*) printf '%s\n' "$TEST_REMOTE_HERDR" ;;
|
||||
*) echo "unexpected discovery: $script" >&2; exit 2 ;;
|
||||
esac ;;
|
||||
*) echo "unexpected command: $last" >&2; exit 2 ;;
|
||||
@@ -39,6 +36,7 @@ esac
|
||||
|
||||
struct Harness {
|
||||
root: PathBuf,
|
||||
state: PathBuf,
|
||||
remote: UnixListener,
|
||||
local: UnixListener,
|
||||
protocol: u64,
|
||||
@@ -65,6 +63,24 @@ impl Harness {
|
||||
fs::write(root.join("bin/ssh"), SSH).unwrap();
|
||||
fs::set_permissions(root.join("bin/ssh"), fs::Permissions::from_mode(0o700)).unwrap();
|
||||
std::os::unix::fs::symlink(env!("CARGO_BIN_EXE_herdr"), root.join("remote herdr")).unwrap();
|
||||
fs::create_dir_all(root.join("remote bin")).unwrap();
|
||||
let remote_wrapper = root.join("remote bin/herdr");
|
||||
fs::write(
|
||||
&remote_wrapper,
|
||||
r#"#!/bin/sh
|
||||
if [ "$TEST_MODE" = old ]; then printf 'herdr-api-bridge-v1\n'; exit 2; fi
|
||||
case "$*" in
|
||||
*'--check')
|
||||
if IFS= read -r request; then
|
||||
echo 'capability check consumed API stdin' >&2
|
||||
exit 2
|
||||
fi ;;
|
||||
esac
|
||||
exec "$TEST_REMOTE_HERDR" "$@"
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
fs::set_permissions(&remote_wrapper, fs::Permissions::from_mode(0o700)).unwrap();
|
||||
fs::write(state.join("endpoints.json"), serde_json::to_vec(&json!({
|
||||
"version": 1,
|
||||
"ssh": [{"id": PROFILE_ID, "label": "mac", "target": "fake-mac", "session": "fleet", "enabled": true}]
|
||||
@@ -80,6 +96,7 @@ impl Harness {
|
||||
let status: Value = serde_json::from_slice(&status.stdout).unwrap();
|
||||
Self {
|
||||
root,
|
||||
state,
|
||||
remote,
|
||||
local,
|
||||
protocol: status["protocol"].as_u64().unwrap(),
|
||||
@@ -138,6 +155,9 @@ impl Harness {
|
||||
.unwrap();
|
||||
let request: Value = serde_json::from_str(&line).unwrap();
|
||||
let ping = request["method"] == "ping";
|
||||
if !ping && result.is_null() {
|
||||
return request;
|
||||
}
|
||||
let response = if ping {
|
||||
json!({"id": request["id"], "result": {"type": "pong", "version": "test", "protocol": protocol}})
|
||||
} else {
|
||||
@@ -154,6 +174,23 @@ impl Harness {
|
||||
})
|
||||
}
|
||||
|
||||
fn warm_metadata(&self) {
|
||||
let server = self.serve(json!({}), 0);
|
||||
success(
|
||||
self.command(&["--machine", "mac", "status", "server", "--json"])
|
||||
.output()
|
||||
.unwrap(),
|
||||
);
|
||||
assert_eq!(server.join().unwrap()["method"], "ping");
|
||||
}
|
||||
|
||||
fn ssh_calls(&self) -> usize {
|
||||
fs::read_to_string(self.root.join("ssh-calls"))
|
||||
.unwrap_or_default()
|
||||
.lines()
|
||||
.count()
|
||||
}
|
||||
|
||||
fn assert_local_untouched(&self) {
|
||||
assert_eq!(
|
||||
self.local.accept().unwrap_err().kind(),
|
||||
@@ -205,7 +242,8 @@ fn machine_api_routes_structured_payload_and_remote_errors_without_local_fallbac
|
||||
let ssh_args = fs::read_to_string(harness.root.join("ssh-args")).unwrap();
|
||||
assert!(ssh_args.contains("StrictHostKeyChecking=yes"));
|
||||
assert!(ssh_args.contains("BatchMode=yes"));
|
||||
assert!(ssh_args.contains("--session fleet remote-api-bridge"));
|
||||
assert!(ssh_args.contains("remote-api-bridge"));
|
||||
assert!(ssh_args.contains("fleet"));
|
||||
assert!(!ssh_args.contains("should-not-exist"));
|
||||
harness.assert_local_untouched();
|
||||
}
|
||||
@@ -226,6 +264,209 @@ fn machine_api_profile_id_routes_large_list_responses() {
|
||||
);
|
||||
assert_eq!(response["result"]["test_data"], data);
|
||||
assert_eq!(server.join().unwrap()["method"], "agent.list");
|
||||
assert_eq!(
|
||||
fs::read_to_string(harness.root.join("ssh-calls"))
|
||||
.unwrap()
|
||||
.lines()
|
||||
.count(),
|
||||
4,
|
||||
"cold command: platform, discovery, protocol ping, request"
|
||||
);
|
||||
harness.assert_local_untouched();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn machine_api_bootstrap_falls_back_from_an_old_path_binary() {
|
||||
let harness = Harness::new();
|
||||
fs::create_dir_all(harness.root.join(".local/bin")).unwrap();
|
||||
std::os::unix::fs::symlink(
|
||||
env!("CARGO_BIN_EXE_herdr"),
|
||||
harness.root.join(".local/bin/herdr"),
|
||||
)
|
||||
.unwrap();
|
||||
let server = harness.serve(
|
||||
json!({"result":{"type":"agent_list","agents":[]}}),
|
||||
harness.protocol,
|
||||
);
|
||||
success(
|
||||
harness
|
||||
.command(&["--machine", "mac", "agent", "list"])
|
||||
.env("TEST_MODE", "old")
|
||||
.output()
|
||||
.unwrap(),
|
||||
);
|
||||
assert_eq!(server.join().unwrap()["method"], "agent.list");
|
||||
harness.assert_local_untouched();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn machine_api_reuses_discovery_across_commands_without_rewriting_profiles() {
|
||||
let harness = Harness::new();
|
||||
let catalog_before = fs::read(harness.state.join("endpoints.json")).unwrap();
|
||||
for expected_calls in [None, Some(2)] {
|
||||
let before = harness.ssh_calls();
|
||||
let server = harness.serve(
|
||||
json!({"result":{"type":"agent_list","agents":[]}}),
|
||||
harness.protocol,
|
||||
);
|
||||
success(
|
||||
harness
|
||||
.command(&["--machine", "mac", "agent", "list"])
|
||||
.output()
|
||||
.unwrap(),
|
||||
);
|
||||
assert_eq!(server.join().unwrap()["method"], "agent.list");
|
||||
if let Some(expected) = expected_calls {
|
||||
assert_eq!(
|
||||
harness.ssh_calls() - before,
|
||||
expected,
|
||||
"warm commands must skip discovery"
|
||||
);
|
||||
}
|
||||
}
|
||||
let before = harness.ssh_calls();
|
||||
let server = harness.serve(json!({}), 0);
|
||||
success(
|
||||
harness
|
||||
.command(&["--machine", "mac", "status", "server", "--json"])
|
||||
.output()
|
||||
.unwrap(),
|
||||
);
|
||||
assert_eq!(server.join().unwrap()["method"], "ping");
|
||||
assert_eq!(
|
||||
harness.ssh_calls() - before,
|
||||
1,
|
||||
"warm status needs only one SSH"
|
||||
);
|
||||
assert_eq!(
|
||||
fs::read(harness.state.join("endpoints.json")).unwrap(),
|
||||
catalog_before
|
||||
);
|
||||
harness.assert_local_untouched();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn machine_api_recovers_a_stale_path_before_sending_a_mutation() {
|
||||
let harness = Harness::new();
|
||||
harness.warm_metadata();
|
||||
fs::remove_file(harness.root.join("remote bin/herdr")).unwrap();
|
||||
fs::create_dir_all(harness.root.join(".local/bin")).unwrap();
|
||||
std::os::unix::fs::symlink(
|
||||
env!("CARGO_BIN_EXE_herdr"),
|
||||
harness.root.join(".local/bin/herdr"),
|
||||
)
|
||||
.unwrap();
|
||||
let before = harness.ssh_calls();
|
||||
let server = harness.serve(json!({"result":{"type":"ok"}}), harness.protocol);
|
||||
success(
|
||||
harness
|
||||
.command(&["--machine", "mac", "pane", "close", "w4:p1"])
|
||||
.output()
|
||||
.unwrap(),
|
||||
);
|
||||
assert_eq!(server.join().unwrap()["method"], "pane.close");
|
||||
assert_eq!(
|
||||
harness.ssh_calls() - before,
|
||||
5,
|
||||
"one failed ping followed by fresh discovery and one command"
|
||||
);
|
||||
let before = harness.ssh_calls();
|
||||
harness.warm_metadata();
|
||||
assert_eq!(
|
||||
harness.ssh_calls() - before,
|
||||
1,
|
||||
"recovered path must be saved"
|
||||
);
|
||||
harness.assert_local_untouched();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn machine_api_never_replays_a_mutation_when_its_response_is_lost() {
|
||||
let harness = Harness::new();
|
||||
harness.warm_metadata();
|
||||
let before = harness.ssh_calls();
|
||||
let server = harness.serve(Value::Null, harness.protocol);
|
||||
let output = harness
|
||||
.command(&["--machine", "mac", "pane", "close", "w4:p1"])
|
||||
.output()
|
||||
.unwrap();
|
||||
assert!(!output.status.success());
|
||||
assert_eq!(server.join().unwrap()["method"], "pane.close");
|
||||
assert_eq!(
|
||||
harness.ssh_calls() - before,
|
||||
2,
|
||||
"mutation must not trigger rediscovery or replay"
|
||||
);
|
||||
harness.assert_local_untouched();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn machine_api_transient_connection_failure_keeps_working_metadata() {
|
||||
let harness = Harness::new();
|
||||
harness.warm_metadata();
|
||||
let before = harness.ssh_calls();
|
||||
let output = harness
|
||||
.command(&["--machine", "mac", "agent", "list"])
|
||||
.env("TEST_MODE", "offline")
|
||||
.output()
|
||||
.unwrap();
|
||||
assert!(!output.status.success());
|
||||
assert_eq!(
|
||||
harness.ssh_calls() - before,
|
||||
1,
|
||||
"network failures must not retry"
|
||||
);
|
||||
let before = harness.ssh_calls();
|
||||
harness.warm_metadata();
|
||||
assert_eq!(
|
||||
harness.ssh_calls() - before,
|
||||
1,
|
||||
"transient failure must not discard metadata"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn machine_remove_deletes_only_that_profiles_metadata() {
|
||||
let harness = Harness::new();
|
||||
harness.warm_metadata();
|
||||
let directory = harness.state.join("ssh-metadata");
|
||||
let other = directory.join("fedcba9876543210fedcba9876543210.json");
|
||||
fs::write(&other, "other machine metadata").unwrap();
|
||||
let before = harness.ssh_calls();
|
||||
let output = harness
|
||||
.command(&["machine", "remove", PROFILE_ID])
|
||||
.output()
|
||||
.unwrap();
|
||||
assert!(
|
||||
output.status.success(),
|
||||
"{}",
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
);
|
||||
assert!(!directory.join(format!("{PROFILE_ID}.json")).exists());
|
||||
assert_eq!(fs::read_to_string(other).unwrap(), "other machine metadata");
|
||||
assert_eq!(harness.ssh_calls(), before);
|
||||
harness.assert_local_untouched();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn machine_api_bad_or_unwritable_metadata_does_not_block_commands() {
|
||||
let harness = Harness::new();
|
||||
harness.warm_metadata();
|
||||
let path = harness
|
||||
.state
|
||||
.join("ssh-metadata")
|
||||
.join(format!("{PROFILE_ID}.json"));
|
||||
fs::write(&path, "broken metadata").unwrap();
|
||||
let before = harness.ssh_calls();
|
||||
harness.warm_metadata();
|
||||
assert_eq!(harness.ssh_calls() - before, 3);
|
||||
fs::remove_file(&path).unwrap();
|
||||
fs::create_dir(&path).unwrap();
|
||||
for _ in 0..2 {
|
||||
let before = harness.ssh_calls();
|
||||
harness.warm_metadata();
|
||||
assert_eq!(harness.ssh_calls() - before, 3);
|
||||
}
|
||||
harness.assert_local_untouched();
|
||||
}
|
||||
|
||||
@@ -303,6 +544,14 @@ fn machine_api_status_reports_remote_identity_not_local_installation_state() {
|
||||
assert_eq!(status["socket"], format!("machine:{PROFILE_ID}/fleet"));
|
||||
assert!(status["server_binary_stale"].is_null());
|
||||
assert_eq!(server.join().unwrap()["method"], "ping");
|
||||
assert_eq!(
|
||||
fs::read_to_string(harness.root.join("ssh-calls"))
|
||||
.unwrap()
|
||||
.lines()
|
||||
.count(),
|
||||
3,
|
||||
"cold status: platform, discovery, status"
|
||||
);
|
||||
harness.assert_local_untouched();
|
||||
}
|
||||
|
||||
@@ -389,6 +638,7 @@ fn machine_api_server_stop_is_sent_only_to_the_selected_machine() {
|
||||
#[test]
|
||||
fn machine_api_protocol_mismatch_never_sends_the_mutation() {
|
||||
let harness = Harness::new();
|
||||
harness.warm_metadata();
|
||||
let server = harness.serve(json!({}), 0);
|
||||
let output = harness
|
||||
.command(&["--machine", "mac", "pane", "close", "w4:p1"])
|
||||
|
||||
Reference in New Issue
Block a user