From 6e7e986b39755a0958171d752a0eea336ee7aa80 Mon Sep 17 00:00:00 2001 From: Kris Hicks Date: Fri, 22 May 2026 12:00:28 -0700 Subject: [PATCH] fix(gateway): try harder to detect Podman Auto-detection previously treated Podman as available only when the podman CLI was visible on PATH. However, package manager services can run with a restricted PATH, which lets Docker be selected even when a Podman API socket is reachable. Additionally, podman may symlink /var/run/docker.sock to podman's machine unix socket, which would be incorrectly detected as Docker. Worse still: the podman machine may not even be running. This replaces the Podman binary check with a functional HTTP probe against the standard Podman socket paths. The probe requires /_ping to answer with a Libpod-Api-Version header before treating the socket as Podman, which lets the gateway select the embedded Podman driver only when the API is usable. Signed-off-by: Kris Hicks --- crates/openshell-core/src/config.rs | 213 +++++++++++++++++++++++++++- 1 file changed, 207 insertions(+), 6 deletions(-) diff --git a/crates/openshell-core/src/config.rs b/crates/openshell-core/src/config.rs index 98562c8a6..dcec04dfc 100644 --- a/crates/openshell-core/src/config.rs +++ b/crates/openshell-core/src/config.rs @@ -5,12 +5,16 @@ use serde::{Deserialize, Serialize}; use std::fmt; +#[cfg(unix)] +use std::io::{Read, Write}; use std::net::SocketAddr; #[cfg(unix)] use std::os::unix::fs::FileTypeExt; use std::path::{Path, PathBuf}; use std::process::Command; use std::str::FromStr; +#[cfg(unix)] +use std::time::Duration; // ── Public default constants ──────────────────────────────────────────── // @@ -96,8 +100,8 @@ pub fn detect_driver() -> Option { return Some(ComputeDriverKind::Kubernetes); } - // Podman: check if podman binary is available - if is_binary_available("podman") { + // Podman: check for a reachable local API socket. + if is_podman_available() { return Some(ComputeDriverKind::Podman); } @@ -117,6 +121,58 @@ fn is_binary_available(name: &str) -> bool { .is_ok_and(|output| output.status.success()) } +fn is_podman_available() -> bool { + podman_socket_available() +} + +fn podman_socket_available() -> bool { + podman_socket_candidates() + .iter() + .any(|path| podman_socket_responds(path)) +} + +fn podman_socket_candidates() -> Vec { + let socket = std::env::var("OPENSHELL_PODMAN_SOCKET") + .ok() + .filter(|path| !path.trim().is_empty()) + .map(PathBuf::from); + podman_socket_candidates_from_env( + socket, + std::env::var_os("XDG_RUNTIME_DIR").map(PathBuf::from), + std::env::var_os("HOME").map(PathBuf::from), + ) +} + +fn podman_socket_candidates_from_env( + socket: Option, + runtime_dir: Option, + home: Option, +) -> Vec { + let mut candidates = Vec::new(); + + if let Some(path) = socket { + candidates.push(path); + } + + if let Some(runtime_dir) = runtime_dir { + candidates.push(runtime_dir.join("podman/podman.sock")); + } + + #[cfg(target_os = "linux")] + { + candidates.push(PathBuf::from(format!( + "/run/user/{}/podman/podman.sock", + current_uid() + ))); + } + + if let Some(home) = home { + candidates.push(home.join(".local/share/containers/podman/machine/podman.sock")); + } + + candidates +} + fn is_docker_available() -> bool { is_binary_available("docker") || docker_socket_available() } @@ -160,12 +216,88 @@ fn is_unix_socket(path: &Path) -> bool { .is_ok_and(|metadata| metadata.file_type().is_socket()) } +#[cfg(unix)] +fn podman_socket_responds(path: &Path) -> bool { + unix_socket_http_ping(path, |response| { + http_response_is_success(response) && contains_ascii(response, b"Libpod-Api-Version:") + }) +} + +#[cfg(unix)] +fn unix_socket_http_ping(path: &Path, accepts_response: impl FnOnce(&[u8]) -> bool) -> bool { + const PROBE_TIMEOUT: Duration = Duration::from_secs(1); + const PING_REQUEST: &[u8] = + b"GET /_ping HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n"; + + if !is_unix_socket(path) { + return false; + } + + let Ok(mut stream) = std::os::unix::net::UnixStream::connect(path) else { + return false; + }; + if stream.set_read_timeout(Some(PROBE_TIMEOUT)).is_err() + || stream.set_write_timeout(Some(PROBE_TIMEOUT)).is_err() + || stream.write_all(PING_REQUEST).is_err() + { + return false; + } + + let mut response = [0_u8; 512]; + let mut total = 0; + while total < response.len() { + let Ok(n) = stream.read(&mut response[total..]) else { + return false; + }; + if n == 0 { + break; + } + total += n; + if contains_ascii(&response[..total], b"\r\n\r\n") { + break; + } + } + total > 0 && accepts_response(&response[..total]) +} + +#[cfg(unix)] +fn http_response_is_success(response: &[u8]) -> bool { + response.starts_with(b"HTTP/1.1 200") || response.starts_with(b"HTTP/1.0 200") +} + +#[cfg(unix)] +fn contains_ascii(haystack: &[u8], needle: &[u8]) -> bool { + haystack + .windows(needle.len()) + .any(|window| window.eq_ignore_ascii_case(needle)) +} + +#[cfg(all(unix, test))] +fn is_reachable_unix_socket(path: &Path) -> bool { + is_unix_socket(path) && std::os::unix::net::UnixStream::connect(path).is_ok() +} + +#[cfg(all(unix, target_os = "linux"))] +fn current_uid() -> u32 { + use std::os::unix::fs::MetadataExt; + + std::fs::metadata("/proc/self") + .map(|metadata| metadata.uid()) + .unwrap_or(0) +} + #[cfg(not(unix))] fn is_unix_socket(path: &Path) -> bool { let _ = path; false } +#[cfg(not(unix))] +fn podman_socket_responds(path: &Path) -> bool { + let _ = path; + false +} + /// Server configuration. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Config { @@ -593,10 +725,15 @@ const fn default_ssh_session_ttl_secs() -> u64 { #[cfg(test)] mod tests { + #[cfg(unix)] + use super::is_reachable_unix_socket; use super::{ ComputeDriverKind, Config, DEFAULT_SERVICE_ROUTING_DOMAIN, detect_driver, - docker_host_unix_socket_path, is_unix_socket, + docker_host_unix_socket_path, is_unix_socket, podman_socket_candidates_from_env, + podman_socket_responds, }; + #[cfg(unix)] + use std::io::{Read as _, Write as _}; use std::net::SocketAddr; #[cfg(unix)] use std::os::unix::net::UnixListener; @@ -700,9 +837,10 @@ mod tests { } #[test] - fn detect_driver_returns_none_without_k8s_env_or_binaries() { - // When KUBERNETES_SERVICE_HOST is not set and no docker/podman binaries - // or Docker socket are available, detect_driver should return None. + fn detect_driver_returns_none_without_k8s_env_or_local_runtime() { + // When KUBERNETES_SERVICE_HOST is not set, no Docker binary/socket is + // available, and no Podman API socket is available, detect_driver + // should return None. // This test may pass or fail depending on the test environment, // but it documents the expected behavior. let _ = detect_driver(); // Returns Some or None based on environment @@ -726,10 +864,73 @@ mod tests { let _listener = UnixListener::bind(&socket_path).expect("bind unix socket"); assert!(is_unix_socket(&socket_path)); + assert!(is_reachable_unix_socket(&socket_path)); let regular_file = temp_dir.path().join("not-a-socket"); std::fs::write(®ular_file, b"not a socket").expect("write regular file"); assert!(!is_unix_socket(®ular_file)); + assert!(!is_reachable_unix_socket(®ular_file)); + } + + #[cfg(unix)] + #[test] + fn podman_socket_probe_accepts_successful_ping_response() { + let temp_dir = tempfile::tempdir().expect("create temp dir"); + let socket_path = temp_dir.path().join("podman.sock"); + let listener = UnixListener::bind(&socket_path).expect("bind podman socket"); + + let handle = std::thread::spawn(move || { + let (mut stream, _) = listener.accept().expect("accept podman probe"); + let mut request = [0_u8; 128]; + let n = stream.read(&mut request).expect("read podman probe"); + assert!(request[..n].starts_with(b"GET /_ping HTTP/1.1\r\n")); + stream + .write_all( + b"HTTP/1.1 200 OK\r\nLibpod-Api-Version: 5.8.2\r\nContent-Length: 2\r\n\r\nOK", + ) + .expect("write podman ping response"); + }); + + assert!(podman_socket_responds(&socket_path)); + handle.join().expect("probe server exits"); + } + + #[cfg(unix)] + #[test] + fn podman_socket_probe_rejects_docker_ping_response() { + let temp_dir = tempfile::tempdir().expect("create temp dir"); + let socket_path = temp_dir.path().join("podman.sock"); + let listener = UnixListener::bind(&socket_path).expect("bind podman socket"); + + let handle = std::thread::spawn(move || { + let (mut stream, _) = listener.accept().expect("accept podman probe"); + let mut request = [0_u8; 128]; + let n = stream.read(&mut request).expect("read podman probe"); + assert!(request[..n].starts_with(b"GET /_ping HTTP/1.1\r\n")); + stream + .write_all( + b"HTTP/1.1 200 OK\r\nServer: Docker/29.2.1\r\nContent-Length: 2\r\n\r\nOK", + ) + .expect("write docker ping response"); + }); + + assert!(!podman_socket_responds(&socket_path)); + handle.join().expect("probe server exits"); + } + + #[test] + fn podman_socket_candidates_include_env_runtime_and_home_paths() { + let candidates = podman_socket_candidates_from_env( + Some(PathBuf::from("/tmp/custom-podman.sock")), + Some(PathBuf::from("/tmp/runtime")), + Some(PathBuf::from("/tmp/home")), + ); + + assert!(candidates.contains(&PathBuf::from("/tmp/custom-podman.sock"))); + assert!(candidates.contains(&PathBuf::from("/tmp/runtime/podman/podman.sock"))); + assert!(candidates.contains(&PathBuf::from( + "/tmp/home/.local/share/containers/podman/machine/podman.sock" + ))); } #[test]