aboutsummaryrefslogtreecommitdiff
path: root/src/platform
diff options
context:
space:
mode:
authorCan Celik <ogulcancelik@gmail.com>2026-09-22 19:12:29 +0300
committerGitHub <noreply@github.com>2026-09-22 19:12:29 +0300
commit1c6397b1cddb288099ed212a5979732698bd72c2 (patch)
tree0574b428903ea1ce106bc7ba36e213c0418aa9f4 /src/platform
parent7237703dc59102cf3097e833eb896b314007b548 (diff)
fix: reuse ssh authentication and add machine recovery commands (#4490)
* fix: reuse ssh authentication and add in-app mfa recovery refs #3763 * refactor: move ssh recovery to machine cli refs #3763
Diffstat (limited to 'src/platform')
-rw-r--r--src/platform/mod.rs15
-rw-r--r--src/platform/unix_common.rs136
2 files changed, 150 insertions, 1 deletions
diff --git a/src/platform/mod.rs b/src/platform/mod.rs
index 864516da..88f433bf 100644
--- a/src/platform/mod.rs
+++ b/src/platform/mod.rs
@@ -81,7 +81,9 @@ impl ChildExitReason {
}
#[cfg(unix)]
-pub(crate) use unix_common::{classify_child_exit, poll_fd_readable, read_fd};
+pub(crate) use unix_common::{
+ classify_child_exit, poll_fd_readable, read_fd, shared_ssh_control_path,
+};
#[cfg(not(any(unix, windows)))]
pub(crate) fn classify_child_exit(_status: &portable_pty::ExitStatus) -> ChildExitReason {
@@ -712,3 +714,14 @@ mod tests {
);
}
}
+
+#[cfg(not(unix))]
+pub(crate) fn shared_ssh_control_path(
+ _namespace: &std::path::Path,
+ _target: &str,
+) -> std::io::Result<std::path::PathBuf> {
+ Err(std::io::Error::new(
+ std::io::ErrorKind::Unsupported,
+ "interactive SSH recovery requires Unix OpenSSH multiplexing",
+ ))
+}
diff --git a/src/platform/unix_common.rs b/src/platform/unix_common.rs
index 6063edd9..8b76212c 100644
--- a/src/platform/unix_common.rs
+++ b/src/platform/unix_common.rs
@@ -498,3 +498,139 @@ mod tests {
assert_eq!(err.kind(), std::io::ErrorKind::InvalidInput);
}
}
+
+/// Shared OpenSSH sockets outlive individual helpers. Never adopt a directory
+/// belonging to another uid, a symlink, or a directory accessible by others.
+pub(crate) fn shared_ssh_control_path(namespace: &Path, target: &str) -> std::io::Result<PathBuf> {
+ use sha2::{Digest, Sha256};
+ use std::os::unix::{
+ ffi::OsStrExt,
+ fs::{DirBuilderExt, MetadataExt},
+ };
+
+ // Validate the resolved system temp directory, but retain the short /tmp
+ // spelling for sockets. On macOS /tmp resolves to /private/tmp; those extra
+ // bytes would consume the space OpenSSH needs for its staging suffix.
+ let base = Path::new("/tmp");
+ let resolved_base = std::fs::canonicalize(base)?;
+ let metadata = std::fs::symlink_metadata(&resolved_base)?;
+ if !metadata.is_dir() || metadata.uid() != 0 || metadata.mode() & 0o1000 == 0 {
+ return Err(std::io::Error::new(
+ std::io::ErrorKind::PermissionDenied,
+ "unsafe SSH control directory parent",
+ ));
+ }
+ let dir = base.join(format!("hssh-{}", unsafe { libc::geteuid() }));
+ match std::fs::DirBuilder::new().mode(0o700).create(&dir) {
+ Ok(()) => {}
+ Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => {}
+ Err(error) => return Err(error),
+ }
+ validate_shared_ssh_dir(&dir)?;
+ let namespace = if namespace.is_absolute() {
+ namespace.to_owned()
+ } else {
+ std::env::current_dir()?.join(namespace)
+ };
+ let mut hash = Sha256::new();
+ hash.update(namespace.as_os_str().as_bytes());
+ hash.update([0]);
+ hash.update(target.as_bytes());
+ // %C additionally scopes the socket to OpenSSH's resolved destination,
+ // port and jump host, rather than merely the spelling of an alias.
+ // Keep 96 bits of namespace/target hash plus OpenSSH's 160-bit %C.
+ let hash = format!("{:x}", hash.finalize());
+ let path = dir.join(format!("{}-%C", &hash[..24]));
+ // OpenSSH first binds ControlPath + '.' + 16 random characters, then
+ // renames it. Reserve those 17 bytes, not just the final socket's length.
+ let expanded = path.to_string_lossy().replace("%C", &"0".repeat(40));
+ let staging = PathBuf::from(format!("{expanded}.{}", "0".repeat(16)));
+ if !fits_unix_socket_path(&staging) {
+ return Err(std::io::Error::new(
+ std::io::ErrorKind::InvalidInput,
+ "SSH control socket staging path exceeds the Unix socket length limit",
+ ));
+ }
+ Ok(path)
+}
+
+fn validate_shared_ssh_dir(dir: &Path) -> std::io::Result<()> {
+ use std::os::unix::fs::MetadataExt;
+ let metadata = std::fs::symlink_metadata(dir)?;
+ if !metadata.is_dir()
+ || metadata.uid() != unsafe { libc::geteuid() }
+ || metadata.mode() & 0o7777 != 0o700
+ {
+ return Err(std::io::Error::new(
+ std::io::ErrorKind::PermissionDenied,
+ "SSH control directory must be owned by the current user, mode 0700, and not a symlink",
+ ));
+ }
+ Ok(())
+}
+
+#[cfg(test)]
+mod shared_ssh_tests {
+ use super::*;
+
+ #[test]
+ fn shared_ssh_control_path_is_stable_scoped_and_bounded() {
+ let path = shared_ssh_control_path(Path::new("/config/one"), "user@host").unwrap();
+ assert_eq!(
+ path,
+ shared_ssh_control_path(Path::new("/config/one"), "user@host").unwrap()
+ );
+ assert_ne!(
+ path,
+ shared_ssh_control_path(Path::new("/config/two"), "user@host").unwrap()
+ );
+ assert_ne!(
+ path,
+ shared_ssh_control_path(Path::new("/config/one"), "other@host").unwrap()
+ );
+ let expanded = path.to_string_lossy().replace("%C", &"f".repeat(40));
+ assert!(fits_unix_socket_path(&PathBuf::from(&expanded)));
+ // OpenSSH binds this temporary socket before renaming it to ControlPath.
+ assert!(fits_unix_socket_path(&PathBuf::from(format!(
+ "{expanded}.QuuYe7ZFE2HYeAE4"
+ ))));
+ validate_shared_ssh_dir(path.parent().unwrap()).unwrap();
+ }
+
+ #[test]
+ fn shared_ssh_staging_path_fits_with_maximum_uid_width() {
+ let path = shared_ssh_control_path(Path::new("/config/one"), "user@host").unwrap();
+ let directory = path.parent().unwrap();
+ let name = directory.file_name().unwrap().to_string_lossy();
+ let prefix = name.trim_end_matches(|ch: char| ch.is_ascii_digit());
+ let maximum_uid_directory = directory
+ .parent()
+ .unwrap()
+ .join(format!("{prefix}{}", u32::MAX));
+ let expanded = maximum_uid_directory
+ .join(path.file_name().unwrap())
+ .to_string_lossy()
+ .replace("%C", &"f".repeat(40));
+ assert!(fits_unix_socket_path(&PathBuf::from(format!(
+ "{expanded}.QuuYe7ZFE2HYeAE4"
+ ))));
+ }
+
+ #[test]
+ fn shared_ssh_directory_rejects_symlinks_and_public_modes() {
+ use std::os::unix::fs::{symlink, PermissionsExt};
+ let dir = create_remote_ssh_config_dir("ctl").unwrap();
+ let link = dir.join("link");
+ symlink(&dir, &link).unwrap();
+ assert_eq!(
+ validate_shared_ssh_dir(&link).unwrap_err().kind(),
+ std::io::ErrorKind::PermissionDenied
+ );
+ std::fs::set_permissions(&dir, std::fs::Permissions::from_mode(0o755)).unwrap();
+ assert_eq!(
+ validate_shared_ssh_dir(&dir).unwrap_err().kind(),
+ std::io::ErrorKind::PermissionDenied
+ );
+ std::fs::remove_dir_all(dir).unwrap();
+ }
+}