diff --git a/Cargo.toml b/Cargo.toml index bafb0f8..5416e11 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -44,6 +44,10 @@ hyper = { version="0.14", features=["client", "http1", "tcp", "stream"] } hyper-openssl = { version="0.9", optional=true } openssl = { version="0.10", optional=true } +dirs = { version="5.0", optional=true } +hex = { version="0.4", optional=true } +sha2 = { version="0.10", optional=true } + [dev-dependencies] env_logger = "0.9" # Required for examples to run @@ -65,7 +69,7 @@ tls = ["containers-api/tls"] vendored-ssl = ["tls", "containers-api/vendored-ssl"] par-compress = ["containers-api/par-compress"] swarm = [] - +detect-host = ["dep:dirs", "dep:hex", "dep:sha2"] # docs.rs-specific configuration [package.metadata.docs.rs] diff --git a/src/detect_host.rs b/src/detect_host.rs new file mode 100644 index 0000000..32d3535 --- /dev/null +++ b/src/detect_host.rs @@ -0,0 +1,200 @@ +//! Auto detect the docker endpoint the same way docker-cli does +//! +//! Reference: + +use dirs::home_dir; +use env_vars::{DOCKER_CONFIG, DOCKER_CONTEXT, DOCKER_HOST}; +use serde::de::Error as SerdeError; +use sha2::{Digest, Sha256}; +use std::{fs, path::PathBuf}; + +#[cfg(unix)] +pub const DEFAULT_DOCKER_ENDPOINT: &str = "unix:///var/run/docker.sock"; + +/// For windows the default endpoint is "npipe:////./pipe/docker_engine" +/// But currently this is not supported by docker-api, using to default tcp endpoint instead +/// https://github.com/vv9k/docker-api-rs/issues/57 +#[cfg(not(unix))] +pub const DEFAULT_DOCKER_ENDPOINT: &str = "tcp://127.0.0.1:2375"; + +/// List of environment variables supported by the `docker` command +pub(crate) mod env_vars { + use std::ffi::OsStr; + + /// The location of your client configuration files. + pub const DOCKER_CONFIG: &str = "DOCKER_CONFIG"; + + /// Name of the `docker context` to use (overrides `DOCKER_HOST` env var and default context set with `docker context use`) + pub const DOCKER_CONTEXT: &str = "DOCKER_CONTEXT"; + + /// Daemon socket to connect to. + pub const DOCKER_HOST: &str = "DOCKER_HOST"; + + /// Load an environment variable and verify if it's not empty + pub fn non_empty_var>(key: K) -> Option { + let value = std::env::var(key).ok()?; + if value.trim().is_empty() { + None + } else { + Some(value) + } + } +} + +#[derive(Debug)] +pub enum EndpointError { + CannotFindUserHomeDir, + InvalidJson { + filepath: PathBuf, + error: serde_json::Error, + }, + IOError(std::io::Error), +} + +/// Find the docker host the same way as docker-cli does +/// +/// # Steps +/// 1. Try to load the endpoint from the `DOCKER_CONTEXT` environment variable +/// 2. Try to load the endpoint from the `DOCKER_HOST` environment variable +/// 3. Try to load the endpoint from the `config.json` file +/// 4. Return the default endpoint +/// +/// # Fails when +/// * Cannot find docker config directory +/// * `DOCKER_CONTEXT` is defined and is invalid +/// * `config.json` file exists and fails to read or parse it +/// * `config.json` have the `currentContext` property defined, but fails to find the context endpoint +pub fn find_docker_host() -> Result { + // If defined, Load the endpoint from the `DOCKER_CONTEXT` environment variable + if let Some(context) = env_vars::non_empty_var(DOCKER_CONTEXT) { + let config_directory = docker_config_dir()?; + return host_from_context(&context, config_directory); + } + + // If defined, return the host from the `DOCKER_HOST` environment variable + if let Some(host) = env_vars::non_empty_var(DOCKER_HOST) { + return Ok(host); + } + + // If the config.json file exists, try to load the endpoint from it + let config_file = docker_config_dir().map(|config_dir| config_dir.join("config.json"))?; + if config_file.exists() { + let maybe_host = host_from_config_file(config_file)?; + return Ok(maybe_host.unwrap_or_else(|| DEFAULT_DOCKER_ENDPOINT.to_string())); + } + + // otherwise return the default endpoint + Ok(DEFAULT_DOCKER_ENDPOINT.to_string()) +} + +/// By default, the Docker-cli stores its configuration files in a directory called +/// `.docker` within your `$HOME` directory. the default location can be overridden by +/// the `DOCKER_CONFIG` environment variable. +/// +/// Reference: +/// https://github.com/docker/cli/blob/v24.0.5/man/docker-config-json.5.md +/// +/// Fails if the user home directory cannot be found +pub fn docker_config_dir() -> Result { + // Try to load the config directory from the `DOCKER_CONFIG` environment variable + if let Some(config_directory) = env_vars::non_empty_var(DOCKER_CONFIG).map(PathBuf::from) { + return Ok(config_directory); + } + + // Use the default config directory at $HOME/.docker/ + let Some(config_directory) = home_dir().map(|path| path.join(".docker/")) else { + return Err(EndpointError::CannotFindUserHomeDir); + }; + Ok(config_directory) +} + +/// Attempts to load the endpoint from the `.docker/config.json` file +/// +/// # Returns +/// * Ok(Some(host)) - if the config.json exists and contains currentContext field +/// * Ok(None) - if the config.json exists and not contain currentContext field +/// +/// # Fails when +/// * config.json doesn't exists +/// * cannot read or parse the config.js file +/// * the currentContext is defined, but fails to load the context endpoint +pub fn host_from_config_file(config_file: PathBuf) -> Result, EndpointError> { + // Read the config.json file and extract the current context + let config_file_json = file_to_json(&config_file)?; + + // Check if the config file has the property currentContext + let current_context = config_file_json + .get("currentContext") + .and_then(|value| value.as_str()) + .map(str::to_string); + + if let Some(context) = current_context { + let config_directory = config_file + .parent() + .map(|config_dir| config_dir.to_path_buf()) + .unwrap_or_default(); + let endpoint = host_from_context(&context, config_directory)?; + Ok(Some(endpoint)) + } else { + Ok(None) + } +} + +/// Load the Host of a given context, the context's host is located at: +/// UNIX: +/// - $HOME/.docker/contexts/meta//meta.json +/// Windows: +/// - %USERPROFILE%\.docker\contexts\meta\\meta.json +/// +/// Is possible to list contexts by running `docker context ls` +pub fn host_from_context( + context: &str, + mut config_directory: PathBuf, +) -> Result { + let metadata_filepath = { + // $HOME/.docker/contexts/meta//meta.json + let digest = sha256_digest(context); + config_directory.extend(["contexts", "meta", digest.as_str(), "meta.json"]); + config_directory + }; + + host_from_metadata_file(metadata_filepath) +} + +/// Parses the `meta.json` file and extract the docker endpoint +/// The endpoint is located at: `meta.Endpoints.Endpoints.docker.Host` +pub fn host_from_metadata_file(meta_filepath: PathBuf) -> Result { + let meta_json = file_to_json(&meta_filepath)?; + + let host = meta_json + .get("Endpoints") + .and_then(|value| value.get("docker")) + .and_then(|value| value.get("Host")) + .and_then(|value| value.as_str()) + .ok_or_else(|| EndpointError::InvalidJson { + filepath: meta_filepath, + error: SerdeError::missing_field("Endpoints.docker.Host"), + })? + .to_string(); + + Ok(host) +} + +/// Parsers a file to a json value +fn file_to_json(filepath: &PathBuf) -> Result { + fs::read_to_string(filepath) + .map_err(EndpointError::IOError)? + .parse::() + .map_err(|error| EndpointError::InvalidJson { + filepath: filepath.clone(), + error, + }) +} + +/// Returns the sha256 hex-digest of a given string +fn sha256_digest(name: &str) -> String { + let mut hasher = Sha256::new(); + hasher.update(name.as_bytes()); + let result = hasher.finalize(); + hex::encode(result) +} diff --git a/src/docker.rs b/src/docker.rs index 4693752..b3821f1 100644 --- a/src/docker.rs +++ b/src/docker.rs @@ -16,6 +16,9 @@ use crate::conn::get_https_connector; #[cfg(unix)] use crate::conn::get_unix_connector; +#[cfg(feature = "detect-host")] +use crate::detect_host::{find_docker_host, DEFAULT_DOCKER_ENDPOINT}; + use futures_util::{ io::{AsyncRead, AsyncWrite}, stream::Stream, @@ -33,6 +36,20 @@ pub struct Docker { client: RequestClient, } +#[cfg(feature = "detect-host")] +impl Default for Docker { + fn default() -> Self { + // Try to connect to the configured host, otherwise connect to default endpoint + find_docker_host() + .ok() + .and_then(|endpoint| Self::new(endpoint).ok()) + .unwrap_or_else(|| { + Self::new(DEFAULT_DOCKER_ENDPOINT) + .expect("the default endpoint is always valid: qed") + }) + } +} + impl Docker { /// Creates a new Docker instance by automatically choosing appropriate connection type based /// on provided `uri`. diff --git a/src/lib.rs b/src/lib.rs index 730483c..01bc32f 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -39,6 +39,8 @@ pub mod conn { pub(crate) use containers_api::conn::*; pub use containers_api::conn::{Error, Transport, TtyChunk}; } +#[cfg(feature = "detect-host")] +pub mod detect_host; pub mod docker; pub mod errors; pub mod opts;