use std::path::{Path, PathBuf};
use async_trait::async_trait;
use thiserror::Error;
use crate::containers::{ContainerSnapshot, EngineKind};
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ConnectionTarget {
Unix(PathBuf),
Http(String),
}
#[derive(Debug, Error)]
pub enum EngineError {
#[error("container engine connection failed: {0}")]
ConnectFailed(String),
#[error("container not found: {0}")]
ContainerNotFound(String),
#[error("permission denied: {0}")]
PermissionDenied(String),
#[error("operation timed out after {0:?}")]
Timeout(std::time::Duration),
#[error("engine error: {0}")]
Other(String),
}
#[async_trait]
pub trait ContainerEngine: Send + Sync {
async fn list_and_stats(&self) -> Result<Vec<ContainerSnapshot>, EngineError>;
async fn stop(&self, id: &str, timeout_secs: Option<u64>) -> Result<(), EngineError>;
async fn kill(&self, id: &str) -> Result<(), EngineError>;
async fn restart(&self, id: &str) -> Result<(), EngineError>;
fn kind(&self) -> EngineKind;
}
pub trait EnvLookup {
fn var(&self, name: &str) -> Option<String>;
}
pub struct StdEnv;
impl EnvLookup for StdEnv {
fn var(&self, name: &str) -> Option<String> {
std::env::var(name).ok()
}
}
pub fn parse_docker_host(raw: &str) -> Option<ConnectionTarget> {
let raw = raw.trim();
if let Some(rest) = raw.strip_prefix("unix://") {
if rest.is_empty() {
return None;
}
return Some(ConnectionTarget::Unix(PathBuf::from(rest)));
}
if let Some(rest) = raw.strip_prefix("tcp://") {
if rest.is_empty() {
return None;
}
return Some(ConnectionTarget::Http(format!("http://{rest}")));
}
if raw.starts_with("http://") || raw.starts_with("https://") {
return Some(ConnectionTarget::Http(raw.to_string()));
}
None
}
pub fn detect_with<E: EnvLookup>(env: &E, candidates: &[&Path]) -> Option<ConnectionTarget> {
if let Some(raw) = env.var("DOCKER_HOST")
&& let Some(target) = parse_docker_host(&raw)
{
if let ConnectionTarget::Http(url) = &target
&& !http_host_is_loopback(url)
{
tracing::warn!(
target: "muxtop::docker",
host = %url,
"DOCKER_HOST points to a non-loopback target — container metadata will be sent there",
);
}
return Some(target);
}
for candidate in candidates {
if candidate.exists() {
return Some(ConnectionTarget::Unix(candidate.to_path_buf()));
}
}
None
}
fn http_host_is_loopback(url: &str) -> bool {
let after_scheme = url
.strip_prefix("http://")
.or_else(|| url.strip_prefix("https://"))
.unwrap_or(url);
let after_userinfo = match after_scheme.rfind('@') {
Some(idx) => &after_scheme[idx + 1..],
None => after_scheme,
};
let host_port = after_userinfo
.split(['/', '?'])
.next()
.unwrap_or(after_userinfo);
let host = if let Some(stripped) = host_port.strip_prefix('[') {
match stripped.find(']') {
Some(end) => &stripped[..end],
None => stripped, }
} else {
host_port.split(':').next().unwrap_or(host_port)
};
if host.eq_ignore_ascii_case("localhost") {
return true;
}
match host.parse::<std::net::IpAddr>() {
Ok(ip) => ip.is_loopback(),
Err(_) => false,
}
}
pub fn detect_socket() -> Option<ConnectionTarget> {
let env = StdEnv;
let podman_user: Option<PathBuf> = env
.var("XDG_RUNTIME_DIR")
.map(|x| PathBuf::from(x).join("podman/podman.sock"));
let docker = Path::new("/var/run/docker.sock");
let podman_system = Path::new("/run/podman/podman.sock");
let mut candidates: Vec<&Path> = Vec::with_capacity(3);
candidates.push(docker);
if let Some(p) = podman_user.as_deref() {
candidates.push(p);
}
candidates.push(podman_system);
detect_with(&env, &candidates)
}
#[cfg(test)]
mod tests {
use super::*;
use std::collections::HashMap;
use std::fs::File;
use tempfile::tempdir;
#[derive(Default)]
struct FakeEnv {
vars: HashMap<String, String>,
}
impl FakeEnv {
fn with(mut self, key: &str, value: &str) -> Self {
self.vars.insert(key.into(), value.into());
self
}
}
impl EnvLookup for FakeEnv {
fn var(&self, name: &str) -> Option<String> {
self.vars.get(name).cloned()
}
}
#[test]
fn parse_unix_url() {
assert_eq!(
parse_docker_host("unix:///tmp/x"),
Some(ConnectionTarget::Unix(PathBuf::from("/tmp/x")))
);
}
#[test]
fn parse_tcp_url_is_rewritten_to_http() {
assert_eq!(
parse_docker_host("tcp://h:2375"),
Some(ConnectionTarget::Http("http://h:2375".into()))
);
}
#[test]
fn parse_http_url_passes_through() {
assert_eq!(
parse_docker_host("http://h:2375"),
Some(ConnectionTarget::Http("http://h:2375".into()))
);
}
#[test]
fn parse_https_url_passes_through() {
assert_eq!(
parse_docker_host("https://h:2376"),
Some(ConnectionTarget::Http("https://h:2376".into()))
);
}
#[test]
fn parse_rejects_empty_and_garbage() {
assert_eq!(parse_docker_host("unix://"), None);
assert_eq!(parse_docker_host("tcp://"), None);
assert_eq!(parse_docker_host("garbage"), None);
assert_eq!(parse_docker_host(""), None);
}
#[test]
fn detect_with_docker_host_unix_url() {
let env = FakeEnv::default().with("DOCKER_HOST", "unix:///tmp/x");
assert_eq!(
detect_with(&env, &[]),
Some(ConnectionTarget::Unix(PathBuf::from("/tmp/x")))
);
}
#[test]
fn detect_with_docker_host_tcp_url() {
let env = FakeEnv::default().with("DOCKER_HOST", "tcp://h:2375");
assert_eq!(
detect_with(&env, &[]),
Some(ConnectionTarget::Http("http://h:2375".into()))
);
}
#[test]
fn detect_with_fallback_picks_first_existing() {
let dir = tempdir().unwrap();
let first = dir.path().join("first.sock");
let second = dir.path().join("second.sock");
File::create(&first).unwrap();
File::create(&second).unwrap();
let env = FakeEnv::default();
let result = detect_with(&env, &[&first, &second]);
assert_eq!(result, Some(ConnectionTarget::Unix(first)));
}
#[test]
fn detect_with_fallback_skips_missing() {
let dir = tempdir().unwrap();
let missing = dir.path().join("missing.sock");
let present = dir.path().join("present.sock");
File::create(&present).unwrap();
let env = FakeEnv::default();
let result = detect_with(&env, &[&missing, &present]);
assert_eq!(result, Some(ConnectionTarget::Unix(present)));
}
#[test]
fn detect_with_returns_none_when_nothing_found() {
let env = FakeEnv::default();
assert_eq!(detect_with(&env, &[]), None);
}
#[test]
fn detect_with_malformed_docker_host_falls_through_to_filesystem() {
let dir = tempdir().unwrap();
let present = dir.path().join("present.sock");
File::create(&present).unwrap();
let env = FakeEnv::default().with("DOCKER_HOST", "not-a-valid-url");
let result = detect_with(&env, &[&present]);
assert_eq!(result, Some(ConnectionTarget::Unix(present)));
}
#[test]
fn detect_with_empty_docker_host_falls_through() {
let dir = tempdir().unwrap();
let present = dir.path().join("present.sock");
File::create(&present).unwrap();
let env = FakeEnv::default().with("DOCKER_HOST", "");
let result = detect_with(&env, &[&present]);
assert_eq!(result, Some(ConnectionTarget::Unix(present)));
}
#[test]
fn engine_error_display_is_informative() {
let variants: Vec<EngineError> = vec![
EngineError::ConnectFailed("connection refused".into()),
EngineError::ContainerNotFound("abc123".into()),
EngineError::PermissionDenied("docker group".into()),
EngineError::Timeout(std::time::Duration::from_secs(3)),
EngineError::Other("daemon panic".into()),
];
for err in &variants {
let msg = format!("{err}");
assert!(!msg.is_empty(), "empty Display for {err:?}");
}
assert!(format!("{}", variants[0]).contains("connection refused"));
assert!(format!("{}", variants[1]).contains("abc123"));
}
#[test]
fn engine_error_is_send_sync() {
fn assert_send_sync<T: Send + Sync>() {}
assert_send_sync::<EngineError>();
}
#[test]
fn loopback_ipv4_is_loopback() {
assert!(http_host_is_loopback("http://127.0.0.1:2375"));
assert!(http_host_is_loopback("http://127.1.2.3"));
assert!(http_host_is_loopback("https://127.0.0.1:2376/v1.40/info"));
}
#[test]
fn loopback_ipv6_is_loopback() {
assert!(http_host_is_loopback("http://[::1]:2375"));
assert!(http_host_is_loopback("http://[::1]"));
}
#[test]
fn localhost_literal_is_loopback() {
assert!(http_host_is_loopback("http://localhost:2375"));
assert!(http_host_is_loopback("https://LOCALHOST"));
}
#[test]
fn rfc1918_addresses_are_not_loopback() {
assert!(!http_host_is_loopback("http://10.0.0.1:2375"));
assert!(!http_host_is_loopback("http://192.168.1.10:2375"));
assert!(!http_host_is_loopback("https://172.16.0.1"));
}
#[test]
fn public_ipv4_is_not_loopback() {
assert!(!http_host_is_loopback("http://1.2.3.4:2375"));
assert!(!http_host_is_loopback("https://203.0.113.5"));
}
#[test]
fn arbitrary_hostname_is_not_loopback() {
assert!(!http_host_is_loopback("http://docker.example.com:2375"));
}
#[test]
fn detect_with_non_loopback_docker_host_returns_target() {
let env = FakeEnv::default().with("DOCKER_HOST", "tcp://10.0.0.1:2375");
assert_eq!(
detect_with(&env, &[]),
Some(ConnectionTarget::Http("http://10.0.0.1:2375".into()))
);
}
#[test]
fn detect_socket_does_not_panic() {
let _ = detect_socket();
}
}