|
|
|
@@ -34,6 +34,9 @@ const STABLE_UPDATE_MANIFEST_URL: &str = "https://herdr.dev/latest.json";
|
|
|
|
|
const PREVIEW_UPDATE_MANIFEST_URL: &str = "https://herdr.dev/preview.json";
|
|
|
|
|
const REMOTE_BINARY_ENV_VAR: &str = "HERDR_REMOTE_BINARY";
|
|
|
|
|
const REMOTE_OUTPUT_READY_MARKER: &str = "herdr-remote-output-ready:1";
|
|
|
|
|
const WINDOWS_REMOTE_PATH_MARKER: &str = "herdr-remote-path:1:";
|
|
|
|
|
const WINDOWS_REMOTE_INSTALL_DIR_MARKER: &str = "herdr-remote-install-dir:1:";
|
|
|
|
|
const WINDOWS_REMOTE_INSTALL_RESULT_MARKER: &str = "herdr-remote-install-result:1:";
|
|
|
|
|
const SSH_CONTROL_SOCKET_NAME: &str = "ctl";
|
|
|
|
|
pub(crate) fn run_remote(remote: RemoteLaunch) -> io::Result<()> {
|
|
|
|
|
let session_name = crate::session::active_name()
|
|
|
|
@@ -332,6 +335,11 @@ impl RemoteHerdr {
|
|
|
|
|
self.executable = RemoteExecutable::PosixShellPath(shell_path);
|
|
|
|
|
self
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn with_windows_path(mut self, path: String) -> Self {
|
|
|
|
|
self.executable = RemoteExecutable::WindowsPath(path);
|
|
|
|
|
self
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn windows_powershell_application_script(path: &str, args: &[&str]) -> String {
|
|
|
|
@@ -586,6 +594,12 @@ impl RemoteSsh {
|
|
|
|
|
command
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn scp_command(&self) -> Command {
|
|
|
|
|
let mut command = Command::new("scp");
|
|
|
|
|
apply_managed_scp_options(&mut command, self.options());
|
|
|
|
|
command
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn sh_output(&self, script: &str) -> io::Result<Output> {
|
|
|
|
|
let script = posix_remote_output_command(script);
|
|
|
|
|
let mut child = self
|
|
|
|
@@ -688,6 +702,86 @@ impl RemoteSsh {
|
|
|
|
|
)))
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn copy_windows_file(&self, source_path: &Path, remote_path: &str) -> io::Result<()> {
|
|
|
|
|
let status = self
|
|
|
|
|
.scp_command()
|
|
|
|
|
.arg(source_path)
|
|
|
|
|
.arg(windows_scp_target(&self.target, remote_path))
|
|
|
|
|
.status()
|
|
|
|
|
.map_err(|err| io::Error::new(err.kind(), format!("failed to start scp: {err}")))?;
|
|
|
|
|
if status.success() {
|
|
|
|
|
Ok(())
|
|
|
|
|
} else {
|
|
|
|
|
Err(io::Error::other(format!("scp exited with {status}")))
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn install_windows_herdr(
|
|
|
|
|
&self,
|
|
|
|
|
remote_herdr: &RemoteHerdr,
|
|
|
|
|
source_path: &Path,
|
|
|
|
|
identity: &str,
|
|
|
|
|
sha256: &str,
|
|
|
|
|
) -> io::Result<RemoteHerdr> {
|
|
|
|
|
let installer_dir = private_download_dir("windows-installer")?;
|
|
|
|
|
let local_installer_path = installer_dir.join("install.ps1");
|
|
|
|
|
if let Err(err) = fs::write(&local_installer_path, crate::update::WINDOWS_INSTALLER) {
|
|
|
|
|
let _ = fs::remove_dir_all(&installer_dir);
|
|
|
|
|
return Err(err);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let result = (|| {
|
|
|
|
|
let output = self.framed_user_shell_output(&windows_remote_install_prepare_command())?;
|
|
|
|
|
if !output.status.success() {
|
|
|
|
|
return Err(command_failed("remote install preparation failed", &output));
|
|
|
|
|
}
|
|
|
|
|
let remote_dir = parse_windows_remote_path(
|
|
|
|
|
&String::from_utf8_lossy(&output.stdout),
|
|
|
|
|
WINDOWS_REMOTE_INSTALL_DIR_MARKER,
|
|
|
|
|
)?;
|
|
|
|
|
let install_result = (|| {
|
|
|
|
|
self.copy_windows_file(
|
|
|
|
|
&local_installer_path,
|
|
|
|
|
&format!(r"{remote_dir}\install.ps1"),
|
|
|
|
|
)?;
|
|
|
|
|
self.copy_windows_file(
|
|
|
|
|
source_path,
|
|
|
|
|
&format!(r"{remote_dir}\herdr-windows-x86_64.zip"),
|
|
|
|
|
)?;
|
|
|
|
|
|
|
|
|
|
let output = self.framed_user_shell_output(&windows_remote_install_command(
|
|
|
|
|
&remote_dir,
|
|
|
|
|
identity,
|
|
|
|
|
sha256,
|
|
|
|
|
))?;
|
|
|
|
|
if !output.status.success() {
|
|
|
|
|
return Err(command_failed("remote Windows install failed", &output));
|
|
|
|
|
}
|
|
|
|
|
parse_windows_remote_path(
|
|
|
|
|
&String::from_utf8_lossy(&output.stdout),
|
|
|
|
|
WINDOWS_REMOTE_INSTALL_RESULT_MARKER,
|
|
|
|
|
)
|
|
|
|
|
.map(|path| remote_herdr.clone().with_windows_path(path))
|
|
|
|
|
})();
|
|
|
|
|
|
|
|
|
|
let cleanup =
|
|
|
|
|
self.framed_user_shell_output(&windows_remote_install_cleanup_command(&remote_dir));
|
|
|
|
|
match install_result {
|
|
|
|
|
Err(err) => Err(err),
|
|
|
|
|
Ok(installed) => {
|
|
|
|
|
let output = cleanup?;
|
|
|
|
|
if output.status.success() {
|
|
|
|
|
Ok(installed)
|
|
|
|
|
} else {
|
|
|
|
|
Err(command_failed("remote install cleanup failed", &output))
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
})();
|
|
|
|
|
let _ = fs::remove_dir_all(installer_dir);
|
|
|
|
|
result
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Only interactive setup uses this relay. Background probes retain their
|
|
|
|
@@ -804,6 +898,72 @@ fn remote_install_commit_script(tmp_path: &str, dest_path: &str) -> String {
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn windows_remote_install_prepare_command() -> String {
|
|
|
|
|
windows_powershell_script_command(&format!(
|
|
|
|
|
"$remoteInstallDir = Join-Path ([System.IO.Path]::GetTempPath()) ('herdr-remote-' + [System.Guid]::NewGuid().ToString('N')); [System.IO.Directory]::CreateDirectory($remoteInstallDir) | Out-Null; $encodedInstallDir = [System.Convert]::ToBase64String([System.Text.Encoding]::UTF8.GetBytes($remoteInstallDir)); [Console]::Out.WriteLine('{WINDOWS_REMOTE_INSTALL_DIR_MARKER}' + $encodedInstallDir); exit 0"
|
|
|
|
|
))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn windows_scp_target(target: &str, remote_path: &str) -> String {
|
|
|
|
|
let remote_path = remote_path.replace('\\', "/");
|
|
|
|
|
match target.strip_prefix("ssh://") {
|
|
|
|
|
Some(authority) => {
|
|
|
|
|
let mut encoded = String::new();
|
|
|
|
|
for byte in remote_path.bytes() {
|
|
|
|
|
if byte.is_ascii_alphanumeric() || b"-_.~/:".contains(&byte) {
|
|
|
|
|
encoded.push(char::from(byte));
|
|
|
|
|
} else {
|
|
|
|
|
encoded.push_str(&format!("%{byte:02X}"));
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
format!("scp://{authority}/{encoded}")
|
|
|
|
|
}
|
|
|
|
|
None => format!("{target}:{remote_path}"),
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn windows_remote_install_command(remote_dir: &str, identity: &str, sha256: &str) -> String {
|
|
|
|
|
let installer = crate::platform::quote_powershell_arg(&format!(r"{remote_dir}\install.ps1"));
|
|
|
|
|
let package =
|
|
|
|
|
crate::platform::quote_powershell_arg(&format!(r"{remote_dir}\herdr-windows-x86_64.zip"));
|
|
|
|
|
windows_powershell_script_command(&format!(
|
|
|
|
|
r#"$herdrInstaller = {installer}; $herdrPackage = {package}; & powershell.exe -NoLogo -NoProfile -NonInteractive -ExecutionPolicy Bypass -File $herdrInstaller -Channel {channel} -LocalPackagePath $herdrPackage -LocalPackageFormat zip -LocalPackageIdentity {identity} -LocalPackageSha256 {sha256}; if ($LASTEXITCODE -ne 0) {{ exit $LASTEXITCODE }}; $herdrHome = if ([string]::IsNullOrWhiteSpace($env:HERDR_HOME)) {{ Join-Path $env:USERPROFILE '.herdr' }} else {{ $env:HERDR_HOME }}; $activeJunction = Join-Path $herdrHome 'packages\standalone\current'; $activeTarget = [string](Get-Item -LiteralPath $activeJunction -Force -ErrorAction Stop).Target; if ([string]::IsNullOrWhiteSpace($activeTarget)) {{ throw 'Herdr installer did not activate a concrete release.' }}; $installedHerdr = Join-Path $activeTarget 'herdr.exe'; if (-not (Test-Path -LiteralPath $installedHerdr -PathType Leaf)) {{ throw 'Herdr installer result does not contain herdr.exe.' }}; $encodedResult = [System.Convert]::ToBase64String([System.Text.Encoding]::UTF8.GetBytes([System.IO.Path]::GetFullPath($installedHerdr))); [Console]::Out.WriteLine('{WINDOWS_REMOTE_INSTALL_RESULT_MARKER}' + $encodedResult); exit 0"#,
|
|
|
|
|
channel = crate::platform::quote_powershell_arg(current_channel()),
|
|
|
|
|
identity = crate::platform::quote_powershell_arg(identity),
|
|
|
|
|
sha256 = crate::platform::quote_powershell_arg(sha256),
|
|
|
|
|
))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn windows_remote_install_cleanup_command(remote_dir: &str) -> String {
|
|
|
|
|
windows_powershell_script_command(&format!(
|
|
|
|
|
"Remove-Item -LiteralPath {} -Recurse -Force -ErrorAction Stop; exit 0",
|
|
|
|
|
crate::platform::quote_powershell_arg(remote_dir)
|
|
|
|
|
))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn parse_windows_remote_path(stdout: &str, marker: &str) -> io::Result<String> {
|
|
|
|
|
let encoded = stdout
|
|
|
|
|
.lines()
|
|
|
|
|
.rev()
|
|
|
|
|
.find_map(|line| line.trim().strip_prefix(marker))
|
|
|
|
|
.ok_or_else(|| io::Error::other(format!("remote command did not return {marker}")))?;
|
|
|
|
|
decode_windows_remote_path(encoded)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn decode_windows_remote_path(encoded: &str) -> io::Result<String> {
|
|
|
|
|
let bytes = base64::engine::general_purpose::STANDARD
|
|
|
|
|
.decode(encoded)
|
|
|
|
|
.map_err(|err| {
|
|
|
|
|
io::Error::other(format!("remote path result is not valid base64: {err}"))
|
|
|
|
|
})?;
|
|
|
|
|
let path = String::from_utf8(bytes)
|
|
|
|
|
.map_err(|err| io::Error::other(format!("remote path result is not valid UTF-8: {err}")))?;
|
|
|
|
|
if path.is_empty() {
|
|
|
|
|
return Err(io::Error::other("remote path result is empty"));
|
|
|
|
|
}
|
|
|
|
|
Ok(path)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
impl Drop for RemoteSsh {
|
|
|
|
|
fn drop(&mut self) {
|
|
|
|
|
let Some(_options) = self
|
|
|
|
@@ -864,6 +1024,26 @@ fn apply_managed_ssh_options(command: &mut Command, options: Option<&ManagedSshO
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn apply_managed_scp_options(command: &mut Command, options: Option<&ManagedSshOptions>) {
|
|
|
|
|
let Some(options) = options else {
|
|
|
|
|
return;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
command.arg("-F").arg(&options.config_path);
|
|
|
|
|
if let Some(control_path) = &options.control_path {
|
|
|
|
|
command
|
|
|
|
|
.arg("-o")
|
|
|
|
|
.arg(format!(
|
|
|
|
|
"ControlPath={}",
|
|
|
|
|
ssh_config_quote(&control_path.to_string_lossy())
|
|
|
|
|
))
|
|
|
|
|
.arg("-o")
|
|
|
|
|
.arg("ControlMaster=auto")
|
|
|
|
|
.arg("-o")
|
|
|
|
|
.arg("ControlPersist=yes");
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
impl InstallSource {
|
|
|
|
|
fn persistent(path: PathBuf) -> Self {
|
|
|
|
|
Self {
|
|
|
|
@@ -894,7 +1074,12 @@ pub(super) fn prepare_remote_herdr(
|
|
|
|
|
let platform = detect_remote_platform(ssh)?;
|
|
|
|
|
let remote_herdr = RemoteHerdr::for_platform(platform);
|
|
|
|
|
if remote_herdr.platform.is_windows() {
|
|
|
|
|
return prepare_windows_remote_herdr(ssh, remote_herdr, require_surface_interest);
|
|
|
|
|
return prepare_windows_remote_herdr(
|
|
|
|
|
ssh,
|
|
|
|
|
remote_herdr,
|
|
|
|
|
live_handoff_enabled,
|
|
|
|
|
require_surface_interest,
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
let override_binary = remote_binary_override_path()?;
|
|
|
|
|
let remote_binary_candidates = remote_binary_candidates(ssh, &remote_herdr)?;
|
|
|
|
@@ -964,10 +1149,6 @@ pub(super) fn prepare_remote_herdr(
|
|
|
|
|
pub(super) fn find_installed_remote_herdr(ssh: &RemoteSsh) -> io::Result<RemoteHerdr> {
|
|
|
|
|
let platform = detect_remote_platform(ssh)?;
|
|
|
|
|
let remote_herdr = RemoteHerdr::for_platform(platform);
|
|
|
|
|
if remote_herdr.platform.is_windows() {
|
|
|
|
|
return prepare_windows_remote_herdr(ssh, remote_herdr, true)
|
|
|
|
|
.map(|prepared| prepared.remote_herdr);
|
|
|
|
|
}
|
|
|
|
|
let candidates = remote_binary_candidates(ssh, &remote_herdr)?;
|
|
|
|
|
for candidate in candidates {
|
|
|
|
|
if remote_binary_supports_endpoint_requirement(ssh, &candidate, true)? {
|
|
|
|
@@ -987,27 +1168,69 @@ pub(super) fn find_installed_remote_herdr(ssh: &RemoteSsh) -> io::Result<RemoteH
|
|
|
|
|
fn prepare_windows_remote_herdr(
|
|
|
|
|
ssh: &RemoteSsh,
|
|
|
|
|
remote_herdr: RemoteHerdr,
|
|
|
|
|
live_handoff_enabled: bool,
|
|
|
|
|
require_surface_interest: bool,
|
|
|
|
|
) -> io::Result<PreparedRemoteHerdr> {
|
|
|
|
|
if !remote_binary_exists(ssh, &remote_herdr)? {
|
|
|
|
|
return Err(io::Error::new(
|
|
|
|
|
io::ErrorKind::NotFound,
|
|
|
|
|
format!(
|
|
|
|
|
"herdr.exe is not installed or is not on PATH on Windows host {}; install a compatible Windows package with remote host support and retry",
|
|
|
|
|
ssh.target()
|
|
|
|
|
),
|
|
|
|
|
));
|
|
|
|
|
}
|
|
|
|
|
if !remote_binary_supports_endpoint_requirement(ssh, &remote_herdr, require_surface_interest)? {
|
|
|
|
|
return Err(io::Error::other(format!(
|
|
|
|
|
"herdr.exe on Windows host {} does not support saved SSH endpoint federation; install a compatible Windows package with remote host support and retry",
|
|
|
|
|
ssh.target()
|
|
|
|
|
)));
|
|
|
|
|
let override_package = remote_binary_override_path()?;
|
|
|
|
|
let custom_package = override_package.is_some();
|
|
|
|
|
let candidates = remote_binary_candidates(ssh, &remote_herdr)?;
|
|
|
|
|
if !custom_package {
|
|
|
|
|
for candidate in &candidates {
|
|
|
|
|
if remote_binary_supports_endpoint_requirement(
|
|
|
|
|
ssh,
|
|
|
|
|
candidate,
|
|
|
|
|
require_surface_interest,
|
|
|
|
|
)? {
|
|
|
|
|
return Ok(PreparedRemoteHerdr {
|
|
|
|
|
remote_herdr: candidate.clone(),
|
|
|
|
|
stop_after_install_approved: false,
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let stop_after_install_approved = if let Some(candidate) = candidates.first() {
|
|
|
|
|
confirm_remote_install_with_running_server(
|
|
|
|
|
ssh,
|
|
|
|
|
candidate,
|
|
|
|
|
live_handoff_enabled,
|
|
|
|
|
require_surface_interest,
|
|
|
|
|
)?
|
|
|
|
|
} else {
|
|
|
|
|
false
|
|
|
|
|
};
|
|
|
|
|
if !stop_after_install_approved {
|
|
|
|
|
confirm_remote_install(
|
|
|
|
|
&ssh.destination(),
|
|
|
|
|
&remote_herdr,
|
|
|
|
|
&install_source_description(&remote_herdr.platform, override_package.as_deref()),
|
|
|
|
|
)?;
|
|
|
|
|
}
|
|
|
|
|
// Windows needs the complete package, including its app-local ConPTY runtime.
|
|
|
|
|
let source = match override_package {
|
|
|
|
|
Some(path) => InstallSource::persistent(path),
|
|
|
|
|
None => download_release_asset(&remote_herdr.platform)?,
|
|
|
|
|
};
|
|
|
|
|
let install_result = (|| {
|
|
|
|
|
let sha256 = crate::checksum::file_sha256(&source.path)?;
|
|
|
|
|
ssh.install_windows_herdr(
|
|
|
|
|
&remote_herdr,
|
|
|
|
|
&source.path,
|
|
|
|
|
&windows_package_identity(custom_package, &sha256),
|
|
|
|
|
&sha256,
|
|
|
|
|
)
|
|
|
|
|
})();
|
|
|
|
|
source.cleanup();
|
|
|
|
|
let remote_herdr = install_result?;
|
|
|
|
|
if !remote_binary_supports_endpoint_requirement(ssh, &remote_herdr, require_surface_interest)? {
|
|
|
|
|
return Err(io::Error::other(format!(
|
|
|
|
|
"installed remote herdr at {}, but it does not support the required remote hosting capabilities",
|
|
|
|
|
remote_herdr.executable.display()
|
|
|
|
|
)));
|
|
|
|
|
}
|
|
|
|
|
Ok(PreparedRemoteHerdr {
|
|
|
|
|
remote_herdr,
|
|
|
|
|
stop_after_install_approved: false,
|
|
|
|
|
stop_after_install_approved,
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
@@ -1017,11 +1240,7 @@ pub(super) fn find_installed_remote_api_herdr(
|
|
|
|
|
) -> io::Result<RemoteHerdr> {
|
|
|
|
|
let platform = detect_remote_platform(ssh)?;
|
|
|
|
|
let remote_herdr = RemoteHerdr::for_platform(platform);
|
|
|
|
|
let candidates = if remote_herdr.platform.is_windows() {
|
|
|
|
|
vec![remote_herdr]
|
|
|
|
|
} else {
|
|
|
|
|
remote_binary_candidates(ssh, &remote_herdr)?
|
|
|
|
|
};
|
|
|
|
|
let candidates = remote_binary_candidates(ssh, &remote_herdr)?;
|
|
|
|
|
for candidate in candidates {
|
|
|
|
|
let probe =
|
|
|
|
|
ssh.framed_user_shell_output(&remote_api_bridge_command(&candidate, session, true))?;
|
|
|
|
@@ -1120,6 +1339,17 @@ fn remote_binary_candidates(
|
|
|
|
|
ssh: &RemoteSsh,
|
|
|
|
|
remote_herdr: &RemoteHerdr,
|
|
|
|
|
) -> io::Result<Vec<RemoteHerdr>> {
|
|
|
|
|
if remote_herdr.platform.is_windows() {
|
|
|
|
|
let output = ssh.framed_user_shell_output(&windows_remote_binary_candidate_command())?;
|
|
|
|
|
if !output.status.success() {
|
|
|
|
|
return Err(command_failed("remote binary discovery failed", &output));
|
|
|
|
|
}
|
|
|
|
|
return Ok(windows_remote_binary_candidates(
|
|
|
|
|
remote_herdr,
|
|
|
|
|
&String::from_utf8_lossy(&output.stdout),
|
|
|
|
|
));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let mut candidates = Vec::new();
|
|
|
|
|
|
|
|
|
|
if let Some(path_candidate) = remote_binary_on_path_any(ssh, remote_herdr)? {
|
|
|
|
@@ -1140,6 +1370,27 @@ fn remote_binary_candidates(
|
|
|
|
|
Ok(candidates)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn windows_remote_binary_candidate_command() -> String {
|
|
|
|
|
windows_powershell_script_command(&format!(
|
|
|
|
|
r#"function Emit-HerdrPath([string]$CandidatePath) {{ if ([string]::IsNullOrWhiteSpace($CandidatePath) -or -not (Test-Path -LiteralPath $CandidatePath -PathType Leaf)) {{ return }}; $candidateFullPath = [System.IO.Path]::GetFullPath($CandidatePath); $encodedCandidate = [System.Convert]::ToBase64String([System.Text.Encoding]::UTF8.GetBytes($candidateFullPath)); [Console]::Out.WriteLine('{WINDOWS_REMOTE_PATH_MARKER}' + $encodedCandidate) }}; $pathCommand = Get-Command herdr.exe -CommandType Application -ErrorAction SilentlyContinue | Select-Object -First 1; if ($null -ne $pathCommand) {{ Emit-HerdrPath $pathCommand.Source }}; $herdrHome = if ([string]::IsNullOrWhiteSpace($env:HERDR_HOME)) {{ Join-Path $env:USERPROFILE '.herdr' }} else {{ $env:HERDR_HOME }}; $activeJunction = Get-Item -LiteralPath (Join-Path $herdrHome 'packages\standalone\current') -Force -ErrorAction SilentlyContinue; if ($null -ne $activeJunction -and -not [string]::IsNullOrWhiteSpace([string]$activeJunction.Target)) {{ Emit-HerdrPath (Join-Path ([string]$activeJunction.Target) 'herdr.exe') }}; exit 0"#
|
|
|
|
|
))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn windows_remote_binary_candidates(remote_herdr: &RemoteHerdr, stdout: &str) -> Vec<RemoteHerdr> {
|
|
|
|
|
let mut candidates = Vec::new();
|
|
|
|
|
for path in stdout.lines().filter_map(|line| {
|
|
|
|
|
line.trim()
|
|
|
|
|
.strip_prefix(WINDOWS_REMOTE_PATH_MARKER)
|
|
|
|
|
.and_then(|encoded| decode_windows_remote_path(encoded).ok())
|
|
|
|
|
}) {
|
|
|
|
|
push_if_new_remote_binary_candidate(
|
|
|
|
|
&mut candidates,
|
|
|
|
|
remote_herdr.clone().with_windows_path(path),
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
candidates
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn push_if_new_remote_binary_candidate(candidates: &mut Vec<RemoteHerdr>, candidate: RemoteHerdr) {
|
|
|
|
|
if !candidates
|
|
|
|
|
.iter()
|
|
|
|
@@ -1275,8 +1526,11 @@ fn remote_binary_supports_endpoint_requirement(
|
|
|
|
|
remote_herdr: &RemoteHerdr,
|
|
|
|
|
require_surface_interest: bool,
|
|
|
|
|
) -> io::Result<bool> {
|
|
|
|
|
Ok(remote_client_status(ssh, remote_herdr)?
|
|
|
|
|
.is_some_and(|status| status.supports_endpoint_requirement(require_surface_interest)))
|
|
|
|
|
Ok(
|
|
|
|
|
remote_client_status(ssh, remote_herdr)?.is_some_and(|status| {
|
|
|
|
|
status.supports_endpoint_requirement(&remote_herdr.platform, require_surface_interest)
|
|
|
|
|
}),
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn remote_binary_exists(ssh: &RemoteSsh, remote_herdr: &RemoteHerdr) -> io::Result<bool> {
|
|
|
|
@@ -1369,7 +1623,7 @@ fn resolve_install_source(
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn local_binary_can_seed_remote(platform: &RemotePlatform) -> bool {
|
|
|
|
|
if *platform != RemotePlatform::local() {
|
|
|
|
|
if platform.is_windows() || *platform != RemotePlatform::local() {
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
@@ -1378,6 +1632,14 @@ fn local_binary_can_seed_remote(platform: &RemotePlatform) -> bool {
|
|
|
|
|
.unwrap_or(false)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn windows_package_identity(custom: bool, sha256: &str) -> String {
|
|
|
|
|
if custom {
|
|
|
|
|
format!("{}-custom.{sha256}", current_version())
|
|
|
|
|
} else {
|
|
|
|
|
current_version()
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
|
|
|
enum RemoteServerStatus {
|
|
|
|
|
Running {
|
|
|
|
@@ -1643,12 +1905,19 @@ struct RemoteClientStatusJson {
|
|
|
|
|
endpoint_protocol_generation: Option<u32>,
|
|
|
|
|
#[serde(default)]
|
|
|
|
|
endpoint_capabilities: Vec<String>,
|
|
|
|
|
#[serde(default)]
|
|
|
|
|
remote_host_bridge: bool,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
impl RemoteClientStatusJson {
|
|
|
|
|
fn supports_endpoint_requirement(&self, require_surface_interest: bool) -> bool {
|
|
|
|
|
fn supports_endpoint_requirement(
|
|
|
|
|
&self,
|
|
|
|
|
platform: &RemotePlatform,
|
|
|
|
|
require_surface_interest: bool,
|
|
|
|
|
) -> bool {
|
|
|
|
|
self.endpoint_protocol_generation
|
|
|
|
|
== Some(crate::protocol::endpoint::ENDPOINT_PROTOCOL_GENERATION)
|
|
|
|
|
&& (!platform.is_windows() || self.remote_host_bridge)
|
|
|
|
|
&& (!require_surface_interest
|
|
|
|
|
|| [
|
|
|
|
|
crate::protocol::endpoint::SURFACE_INTEREST_CAPABILITY,
|
|
|
|
@@ -2812,6 +3081,20 @@ fn sanitize_path_component(input: &str) -> String {
|
|
|
|
|
mod tests {
|
|
|
|
|
use super::*;
|
|
|
|
|
|
|
|
|
|
fn decode_windows_command(command: &str) -> String {
|
|
|
|
|
let encoded = command
|
|
|
|
|
.strip_prefix("powershell.exe -NoLogo -NoProfile -NonInteractive -EncodedCommand ")
|
|
|
|
|
.expect("encoded PowerShell command");
|
|
|
|
|
let bytes = base64::engine::general_purpose::STANDARD
|
|
|
|
|
.decode(encoded)
|
|
|
|
|
.expect("base64");
|
|
|
|
|
let utf16 = bytes
|
|
|
|
|
.chunks_exact(2)
|
|
|
|
|
.map(|pair| u16::from_le_bytes([pair[0], pair[1]]))
|
|
|
|
|
.collect::<Vec<_>>();
|
|
|
|
|
String::from_utf16(&utf16).expect("UTF-16LE")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[cfg(unix)]
|
|
|
|
|
thread_local! {
|
|
|
|
|
pub(super) static UPLOAD_READ_ATTEMPTS: std::cell::RefCell<Option<Arc<std::sync::atomic::AtomicUsize>>> = const { std::cell::RefCell::new(None) };
|
|
|
|
@@ -3186,7 +3469,8 @@ 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 mut managed_config = write_managed_ssh_config().expect("write managed config");
|
|
|
|
|
managed_config.options.control_path = Some(PathBuf::from("/tmp/herdr test/control"));
|
|
|
|
|
let config_path = managed_config.options.config_path.clone();
|
|
|
|
|
let control_path = managed_config
|
|
|
|
|
.options
|
|
|
|
@@ -3221,6 +3505,25 @@ mod tests {
|
|
|
|
|
"example".to_string(),
|
|
|
|
|
]
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
let scp_args = ssh
|
|
|
|
|
.scp_command()
|
|
|
|
|
.get_args()
|
|
|
|
|
.map(|arg| arg.to_string_lossy().into_owned())
|
|
|
|
|
.collect::<Vec<_>>();
|
|
|
|
|
assert_eq!(
|
|
|
|
|
scp_args,
|
|
|
|
|
vec![
|
|
|
|
|
"-F".to_string(),
|
|
|
|
|
config_path.to_string_lossy().into_owned(),
|
|
|
|
|
"-o".to_string(),
|
|
|
|
|
format!("ControlPath=\"{}\"", control_path.to_string_lossy()),
|
|
|
|
|
"-o".to_string(),
|
|
|
|
|
"ControlMaster=auto".to_string(),
|
|
|
|
|
"-o".to_string(),
|
|
|
|
|
"ControlPersist=yes".to_string(),
|
|
|
|
|
]
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[cfg(windows)]
|
|
|
|
@@ -3263,6 +3566,15 @@ mod tests {
|
|
|
|
|
"example".to_string(),
|
|
|
|
|
]
|
|
|
|
|
);
|
|
|
|
|
let scp_args = ssh
|
|
|
|
|
.scp_command()
|
|
|
|
|
.get_args()
|
|
|
|
|
.map(|arg| arg.to_string_lossy().into_owned())
|
|
|
|
|
.collect::<Vec<_>>();
|
|
|
|
|
assert_eq!(
|
|
|
|
|
scp_args,
|
|
|
|
|
vec!["-F".to_string(), config_path.to_string_lossy().into_owned(),]
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[cfg(windows)]
|
|
|
|
@@ -3341,6 +3653,10 @@ mod tests {
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn saved_machine_compatibility_uses_capabilities_not_release_or_private_protocol() {
|
|
|
|
|
let linux = RemotePlatform {
|
|
|
|
|
os: "linux",
|
|
|
|
|
arch: "x86_64",
|
|
|
|
|
};
|
|
|
|
|
let mut status = RemoteClientStatusJson {
|
|
|
|
|
version: Some("0.1.0".into()),
|
|
|
|
|
protocol: Some(1),
|
|
|
|
@@ -3352,16 +3668,17 @@ mod tests {
|
|
|
|
|
crate::protocol::endpoint::PRESENTATION_EFFECTS_FENCE_CAPABILITY.into(),
|
|
|
|
|
crate::protocol::endpoint::HEALTH_CHECK_CAPABILITY.into(),
|
|
|
|
|
],
|
|
|
|
|
remote_host_bridge: false,
|
|
|
|
|
};
|
|
|
|
|
assert!(status.supports_endpoint_requirement(true));
|
|
|
|
|
assert!(status.supports_endpoint_requirement(&linux, true));
|
|
|
|
|
for index in 0..status.endpoint_capabilities.len() {
|
|
|
|
|
let removed = status.endpoint_capabilities.remove(index);
|
|
|
|
|
assert!(!status.supports_endpoint_requirement(true));
|
|
|
|
|
assert!(status.supports_endpoint_requirement(false));
|
|
|
|
|
assert!(!status.supports_endpoint_requirement(&linux, true));
|
|
|
|
|
assert!(status.supports_endpoint_requirement(&linux, false));
|
|
|
|
|
status.endpoint_capabilities.insert(index, removed);
|
|
|
|
|
}
|
|
|
|
|
status.endpoint_protocol_generation = None;
|
|
|
|
|
assert!(!status.supports_endpoint_requirement(true));
|
|
|
|
|
assert!(!status.supports_endpoint_requirement(&linux, true));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
@@ -3409,6 +3726,7 @@ mod tests {
|
|
|
|
|
.collect::<Vec<_>>();
|
|
|
|
|
|
|
|
|
|
assert_eq!(args, vec!["-T".to_string(), "example".to_string()]);
|
|
|
|
|
assert!(ssh.scp_command().get_args().next().is_none());
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
@@ -3730,20 +4048,6 @@ mod tests {
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn windows_remote_commands_use_one_encoded_powershell_grammar() {
|
|
|
|
|
fn decode(command: &str) -> String {
|
|
|
|
|
let encoded = command
|
|
|
|
|
.strip_prefix("powershell.exe -NoLogo -NoProfile -NonInteractive -EncodedCommand ")
|
|
|
|
|
.expect("encoded PowerShell command");
|
|
|
|
|
let bytes = base64::engine::general_purpose::STANDARD
|
|
|
|
|
.decode(encoded)
|
|
|
|
|
.expect("base64");
|
|
|
|
|
let utf16 = bytes
|
|
|
|
|
.chunks_exact(2)
|
|
|
|
|
.map(|pair| u16::from_le_bytes([pair[0], pair[1]]))
|
|
|
|
|
.collect::<Vec<_>>();
|
|
|
|
|
String::from_utf16(&utf16).expect("UTF-16LE")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let executable = RemoteExecutable::WindowsPath("herdr.exe".to_string());
|
|
|
|
|
let commands = [
|
|
|
|
|
(
|
|
|
|
@@ -3797,7 +4101,7 @@ mod tests {
|
|
|
|
|
"[Console]::Out.WriteLine(); [Console]::Out.WriteLine('{REMOTE_OUTPUT_READY_MARKER}'); [Console]::Out.Flush(); "
|
|
|
|
|
);
|
|
|
|
|
for (label, command, expected) in commands {
|
|
|
|
|
let script = decode(&command);
|
|
|
|
|
let script = decode_windows_command(&command);
|
|
|
|
|
let script = script
|
|
|
|
|
.strip_prefix(&preamble)
|
|
|
|
|
.unwrap_or_else(|| panic!("{label} lacks shared output marker: {script}"));
|
|
|
|
@@ -3805,6 +4109,86 @@ mod tests {
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn windows_install_commands_copy_zip_and_return_concrete_path() {
|
|
|
|
|
assert_eq!(
|
|
|
|
|
windows_scp_target("ssh://user@example:2222", r"C:\Temp\A+B%20 C\ü\install.ps1"),
|
|
|
|
|
"scp://user@example:2222/C:/Temp/A%2BB%2520%20C/%C3%BC/install.ps1"
|
|
|
|
|
);
|
|
|
|
|
assert_eq!(
|
|
|
|
|
windows_scp_target("example", r"C:\Temp\A+B%20 C\install.ps1"),
|
|
|
|
|
"example:C:/Temp/A+B%20 C/install.ps1"
|
|
|
|
|
);
|
|
|
|
|
let remote_dir = r"C:\Temp\Herdr O'Brien\测试";
|
|
|
|
|
assert_eq!(
|
|
|
|
|
windows_scp_target(
|
|
|
|
|
"user@example",
|
|
|
|
|
&format!(r"{remote_dir}\herdr-windows-x86_64.zip")
|
|
|
|
|
),
|
|
|
|
|
"user@example:C:/Temp/Herdr O'Brien/测试/herdr-windows-x86_64.zip"
|
|
|
|
|
);
|
|
|
|
|
assert_eq!(
|
|
|
|
|
windows_scp_target("ssh://user@example:2222", r"C:\Temp\install.ps1"),
|
|
|
|
|
"scp://user@example:2222/C:/Temp/install.ps1"
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
let command = decode_windows_command(&windows_remote_install_command(
|
|
|
|
|
remote_dir,
|
|
|
|
|
"0.9.0-custom.digest",
|
|
|
|
|
&"a".repeat(64),
|
|
|
|
|
));
|
|
|
|
|
assert!(command.contains("-LocalPackageFormat zip"));
|
|
|
|
|
assert!(command.contains("-LocalPackageIdentity 0.9.0-custom.digest"));
|
|
|
|
|
assert!(command.contains(".Target"));
|
|
|
|
|
assert!(command.contains(WINDOWS_REMOTE_INSTALL_RESULT_MARKER));
|
|
|
|
|
|
|
|
|
|
let installed = r"C:\Users\test\测试\.herdr\packages\standalone\releases\0.9.0\herdr.exe";
|
|
|
|
|
let encoded = base64::engine::general_purpose::STANDARD.encode(installed);
|
|
|
|
|
assert_eq!(
|
|
|
|
|
parse_windows_remote_path(
|
|
|
|
|
&format!("installer chatter\n{WINDOWS_REMOTE_INSTALL_RESULT_MARKER}{encoded}\n"),
|
|
|
|
|
WINDOWS_REMOTE_INSTALL_RESULT_MARKER
|
|
|
|
|
)
|
|
|
|
|
.unwrap(),
|
|
|
|
|
installed
|
|
|
|
|
);
|
|
|
|
|
assert!(parse_windows_remote_path(
|
|
|
|
|
"installer chatter",
|
|
|
|
|
WINDOWS_REMOTE_INSTALL_RESULT_MARKER
|
|
|
|
|
)
|
|
|
|
|
.is_err());
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn windows_candidate_discovery_prefers_path_then_active_release() {
|
|
|
|
|
let remote_herdr = RemoteHerdr::for_platform(RemotePlatform {
|
|
|
|
|
os: "windows",
|
|
|
|
|
arch: "x86_64",
|
|
|
|
|
});
|
|
|
|
|
let path = r"C:\stale\herdr.exe";
|
|
|
|
|
let active = r"C:\Users\test\.herdr\packages\standalone\releases\current\herdr.exe";
|
|
|
|
|
let stdout = format!(
|
|
|
|
|
"{WINDOWS_REMOTE_PATH_MARKER}{}\nnoise\n{WINDOWS_REMOTE_PATH_MARKER}{}\n{WINDOWS_REMOTE_PATH_MARKER}{}\n",
|
|
|
|
|
base64::engine::general_purpose::STANDARD.encode(path),
|
|
|
|
|
base64::engine::general_purpose::STANDARD.encode(active),
|
|
|
|
|
base64::engine::general_purpose::STANDARD.encode(active),
|
|
|
|
|
);
|
|
|
|
|
let candidates = windows_remote_binary_candidates(&remote_herdr, &stdout);
|
|
|
|
|
assert_eq!(candidates.len(), 2);
|
|
|
|
|
assert_eq!(
|
|
|
|
|
candidates[0].executable,
|
|
|
|
|
RemoteExecutable::WindowsPath(path.to_string())
|
|
|
|
|
);
|
|
|
|
|
assert_eq!(
|
|
|
|
|
candidates[1].executable,
|
|
|
|
|
RemoteExecutable::WindowsPath(active.to_string())
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
let command = decode_windows_command(&windows_remote_binary_candidate_command());
|
|
|
|
|
assert!(command.contains("Get-Command herdr.exe"));
|
|
|
|
|
assert!(command.contains("$activeJunction.Target"));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn remote_output_framing_discards_any_banner_and_preserves_binary() {
|
|
|
|
|
let payload = [0, 1, 2, 0xff, b'\n'];
|
|
|
|
@@ -4140,6 +4524,10 @@ mod tests {
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn saved_machine_setup_handoffs_old_server_missing_presentation_fence() {
|
|
|
|
|
let linux = RemotePlatform {
|
|
|
|
|
os: "linux",
|
|
|
|
|
arch: "x86_64",
|
|
|
|
|
};
|
|
|
|
|
// Captured from Rohan after installing a new binary while the old daemon stayed alive.
|
|
|
|
|
let installed = parse_client_status_json(
|
|
|
|
|
r#"{"version":"0.8.2","protocol":22,"endpoint_protocol_generation":1,"endpoint_capabilities":["surface_interest","presentation_effects_fence","health_check"]}"#,
|
|
|
|
@@ -4149,8 +4537,8 @@ mod tests {
|
|
|
|
|
r#"{"version":"0.8.2","protocol":22,"endpoint_protocol_generation":1,"endpoint_capabilities":["surface_interest","health_check"]}"#,
|
|
|
|
|
)
|
|
|
|
|
.unwrap();
|
|
|
|
|
assert!(installed.supports_endpoint_requirement(true));
|
|
|
|
|
assert!(!running_binary.supports_endpoint_requirement(true));
|
|
|
|
|
assert!(installed.supports_endpoint_requirement(&linux, true));
|
|
|
|
|
assert!(!running_binary.supports_endpoint_requirement(&linux, true));
|
|
|
|
|
for (live_capabilities, expected) in [
|
|
|
|
|
(
|
|
|
|
|
running_binary.endpoint_capabilities,
|
|
|
|
@@ -4196,6 +4584,38 @@ mod tests {
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn windows_reuse_requires_explicit_remote_host_bridge() {
|
|
|
|
|
let windows = RemotePlatform {
|
|
|
|
|
os: "windows",
|
|
|
|
|
arch: "x86_64",
|
|
|
|
|
};
|
|
|
|
|
let linux = RemotePlatform {
|
|
|
|
|
os: "linux",
|
|
|
|
|
arch: "x86_64",
|
|
|
|
|
};
|
|
|
|
|
let old =
|
|
|
|
|
parse_client_status_json(r#"{"version":"0.8.2","endpoint_protocol_generation":1}"#)
|
|
|
|
|
.unwrap();
|
|
|
|
|
let current = parse_client_status_json(
|
|
|
|
|
r#"{"version":"0.9.0","endpoint_protocol_generation":1,"remote_host_bridge":true}"#,
|
|
|
|
|
)
|
|
|
|
|
.unwrap();
|
|
|
|
|
|
|
|
|
|
assert!(!old.supports_endpoint_requirement(&windows, false));
|
|
|
|
|
assert!(current.supports_endpoint_requirement(&windows, false));
|
|
|
|
|
assert!(old.supports_endpoint_requirement(&linux, false));
|
|
|
|
|
assert!(!local_binary_can_seed_remote(&windows));
|
|
|
|
|
assert_eq!(
|
|
|
|
|
windows_package_identity(true, &"a".repeat(64)),
|
|
|
|
|
format!("{}-custom.{}", current_version(), "a".repeat(64))
|
|
|
|
|
);
|
|
|
|
|
assert_eq!(
|
|
|
|
|
windows_package_identity(false, "ignored"),
|
|
|
|
|
current_version()
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn parse_remote_server_status_json_reads_running_server() {
|
|
|
|
|
assert_eq!(
|
|
|
|
|