From e7f294ab06899a383a28bf05d22682963aac9d24 Mon Sep 17 00:00:00 2001 From: "Agusti F." <6601142+agustif@users.noreply.github.com> Date: Thu, 12 Mar 2026 12:44:02 +0100 Subject: [PATCH] Add TLS-aware Docker context resolution in coast-docker Extend `coast-docker::host` so context-driven Docker endpoints can resolve TLS transport from Docker context storage. Preserve explicit env-driven DOCKER_HOST / DOCKER_TLS_VERIFY / DOCKER_CERT_PATH behavior by continuing to defer that path to Bollard's env-aware defaults. Reject ssh:// contexts explicitly in this slice. Refs: #63 Co-authored-by: Codex --- Cargo.lock | 53 +++++++++- Cargo.toml | 2 +- coast-docker/src/host.rs | 208 +++++++++++++++++++++++++++++++++++++-- 3 files changed, 253 insertions(+), 10 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 1a31b3a..1ab8d9f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -258,14 +258,20 @@ dependencies = [ "futures-core", "futures-util", "hex", + "home", "http", "http-body-util", "hyper", "hyper-named-pipe", + "hyper-rustls", "hyper-util", "hyperlocal", "log", "pin-project-lite", + "rustls", + "rustls-native-certs", + "rustls-pemfile", + "rustls-pki-types", "serde", "serde_derive", "serde_json", @@ -931,7 +937,7 @@ dependencies = [ "libc", "libgit2-sys", "log", - "openssl-probe", + "openssl-probe 0.1.6", "openssl-sys", "url", ] @@ -1042,6 +1048,15 @@ dependencies = [ "url", ] +[[package]] +name = "home" +version = "0.5.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc627f471c528ff0c4a49e1d5e60450c8f6461dd6d10ba9dcd3a61d3dff7728d" +dependencies = [ + "windows-sys 0.61.2", +] + [[package]] name = "http" version = "1.4.0" @@ -1663,6 +1678,12 @@ version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d05e27ee213611ffe7d6348b942e8f942b37114c00cc03cec254295a4a17852e" +[[package]] +name = "openssl-probe" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" + [[package]] name = "openssl-sys" version = "0.9.111" @@ -2176,6 +2197,27 @@ dependencies = [ "zeroize", ] +[[package]] +name = "rustls-native-certs" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "612460d5f7bea540c490b2b6395d8e34a953e52b491accd6c86c8164c5932a63" +dependencies = [ + "openssl-probe 0.2.1", + "rustls-pki-types", + "schannel", + "security-framework", +] + +[[package]] +name = "rustls-pemfile" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dce314e5fee3f39953d46bb63bb8a46d40c2f8fb7cc5a3b6cab2bde9721d6e50" +dependencies = [ + "rustls-pki-types", +] + [[package]] name = "rustls-pki-types" version = "1.14.0" @@ -2218,6 +2260,15 @@ dependencies = [ "winapi-util", ] +[[package]] +name = "schannel" +version = "0.1.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939" +dependencies = [ + "windows-sys 0.61.2", +] + [[package]] name = "schemars" version = "0.9.0" diff --git a/Cargo.toml b/Cargo.toml index cf67ffc..6742f13 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -29,7 +29,7 @@ toml = "0.8" clap = { version = "4", features = ["derive", "env"] } # Docker -bollard = "0.18" +bollard = { version = "0.18", features = ["ssl"] } # Database rusqlite = { version = "0.32", features = ["bundled"] } diff --git a/coast-docker/src/host.rs b/coast-docker/src/host.rs index f16ac4c..5726e54 100644 --- a/coast-docker/src/host.rs +++ b/coast-docker/src/host.rs @@ -28,6 +28,14 @@ pub struct DockerEndpoint { pub host: String, pub source: DockerEndpointSource, pub context: Option, + pub tls: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct DockerTlsMaterial { + pub ca_path: PathBuf, + pub cert_path: PathBuf, + pub key_path: PathBuf, } #[derive(Debug, Deserialize)] @@ -48,6 +56,8 @@ struct ContextMeta { struct ContextEndpoint { #[serde(rename = "Host")] host: Option, + #[serde(rename = "SkipTLSVerify")] + skip_tls_verify: Option, } pub fn connect_to_host_docker() -> Result { @@ -95,14 +105,16 @@ pub fn resolve_docker_endpoint( host: DEFAULT_LOCAL_DOCKER_HOST.to_string(), source: DockerEndpointSource::DefaultLocal, context: None, + tls: None, }); } - let host = resolve_context_host(config_dir.as_deref(), raw_context)?; + let resolved = resolve_context_endpoint(config_dir.as_deref(), raw_context)?; return Ok(DockerEndpoint { - host, + host: resolved.host, source: DockerEndpointSource::EnvContext, context: Some(raw_context.to_string()), + tls: resolved.tls, }); } @@ -111,16 +123,18 @@ pub fn resolve_docker_endpoint( host: host.to_string(), source: DockerEndpointSource::EnvHost, context: None, + tls: None, }); } if let Some(config_dir) = config_dir.as_deref() { if let Some(context) = current_context_from_config(config_dir)? { - let host = resolve_context_host(Some(config_dir), &context)?; + let resolved = resolve_context_endpoint(Some(config_dir), &context)?; return Ok(DockerEndpoint { - host, + host: resolved.host, source: DockerEndpointSource::ConfigContext, context: Some(context), + tls: resolved.tls, }); } } @@ -129,6 +143,7 @@ pub fn resolve_docker_endpoint( host: DEFAULT_LOCAL_DOCKER_HOST.to_string(), source: DockerEndpointSource::DefaultLocal, context: None, + tls: None, }) } @@ -151,6 +166,43 @@ fn connect_to_endpoint(endpoint: &DockerEndpoint) -> Result { }); } + if host.starts_with("ssh://") { + return Err(CoastError::docker(format!( + "Unsupported Docker endpoint '{}' from {context_msg}. \ + SSH Docker contexts are out of scope for this resolver; set DOCKER_HOST explicitly to a supported transport.", + endpoint.host + ))); + } + + if let Some(ref tls) = endpoint.tls { + return Docker::connect_with_ssl( + host, + &tls.key_path, + &tls.cert_path, + &tls.ca_path, + DEFAULT_TIMEOUT_SECS, + API_DEFAULT_VERSION, + ) + .map_err(|e| { + CoastError::docker(format!( + "Failed to connect to {context_msg} at '{}' using TLS material from '{}'. Error: {e}", + endpoint.host, + tls.ca_path + .parent() + .map(Path::display) + .map(|path| path.to_string()) + .unwrap_or_else(|| "".to_string()) + )) + }); + } + + if host.starts_with("https://") { + return Err(CoastError::docker(format!( + "Docker endpoint '{}' from {context_msg} requires TLS material, but none was found in the Docker context storage.", + endpoint.host + ))); + } + if host.starts_with("tcp://") || host.starts_with("http://") { return Docker::connect_with_http(host, DEFAULT_TIMEOUT_SECS, API_DEFAULT_VERSION) .map_err(|e| { @@ -208,7 +260,12 @@ fn current_context_from_config(config_dir: &Path) -> Result> { Ok(normalize_context_name(config.current_context.as_deref())) } -fn resolve_context_host(config_dir: Option<&Path>, context_name: &str) -> Result { +struct ResolvedContextEndpoint { + host: String, + tls: Option, +} + +fn resolve_context_endpoint(config_dir: Option<&Path>, context_name: &str) -> Result { let Some(config_dir) = config_dir else { return Err(CoastError::docker(format!( "Docker context '{context_name}' was requested, but no Docker config directory could be found." @@ -262,10 +319,19 @@ fn resolve_context_host(config_dir: Option<&Path>, context_name: &str) -> Result continue; } - let host = meta + let endpoint = meta .endpoints .get("docker") - .and_then(|endpoint| endpoint.host.as_deref()) + .ok_or_else(|| { + CoastError::docker(format!( + "Docker context '{context_name}' has no docker endpoint metadata in '{}'.", + meta_path.display() + )) + })?; + + let host = endpoint + .host + .as_deref() .map(str::trim) .filter(|value| !value.is_empty()) .ok_or_else(|| { @@ -275,7 +341,12 @@ fn resolve_context_host(config_dir: Option<&Path>, context_name: &str) -> Result )) })?; - return Ok(host.to_string()); + let tls = resolve_context_tls_material(config_dir, &meta_path, host, endpoint)?; + + return Ok(ResolvedContextEndpoint { + host: host.to_string(), + tls, + }); } Err(CoastError::docker(format!( @@ -284,6 +355,63 @@ fn resolve_context_host(config_dir: Option<&Path>, context_name: &str) -> Result ))) } +fn resolve_context_tls_material( + config_dir: &Path, + meta_path: &Path, + host: &str, + endpoint: &ContextEndpoint, +) -> Result> { + if host.starts_with("unix://") || host.starts_with("npipe://") || host.starts_with("ssh://") { + return Ok(None); + } + + let Some(hash_dir) = meta_path.parent().and_then(Path::file_name) else { + return Err(CoastError::docker(format!( + "Could not resolve the Docker context storage directory for '{}'.", + meta_path.display() + ))); + }; + + let tls_root = config_dir.join("contexts").join("tls").join(hash_dir); + let search_roots = [tls_root.join("docker"), tls_root.clone()]; + let pem_names = ["ca.pem", "cert.pem", "key.pem"]; + + for root in &search_roots { + let found: Vec = pem_names.iter().map(|name| root.join(name)).collect(); + let existing_count = found.iter().filter(|path| path.exists()).count(); + + if existing_count == 0 { + continue; + } + + if existing_count != pem_names.len() { + return Err(CoastError::docker(format!( + "Docker context '{}' has partial TLS material in '{}'. Expected ca.pem, cert.pem, and key.pem.", + host, + root.display() + ))); + } + + return Ok(Some(DockerTlsMaterial { + ca_path: found[0].clone(), + cert_path: found[1].clone(), + key_path: found[2].clone(), + })); + } + + if host.starts_with("https://") + || (host.starts_with("tcp://") && endpoint.skip_tls_verify.unwrap_or(false)) + { + return Err(CoastError::docker(format!( + "Docker context '{}' requires TLS, but no TLS material was found under '{}'.", + host, + tls_root.display() + ))); + } + + Ok(None) +} + #[cfg(test)] mod tests { use super::*; @@ -331,6 +459,7 @@ mod tests { "unix:///Users/test/.orbstack/run/docker.sock" ); assert_eq!(endpoint.context.as_deref(), Some("orbstack")); + assert_eq!(endpoint.tls, None); } #[test] @@ -371,6 +500,7 @@ mod tests { assert_eq!(endpoint.source, DockerEndpointSource::ConfigContext); assert_eq!(endpoint.context.as_deref(), Some("orbstack")); + assert_eq!(endpoint.tls, None); } #[test] @@ -384,6 +514,7 @@ mod tests { assert_eq!(endpoint.source, DockerEndpointSource::DefaultLocal); assert_eq!(endpoint.host, DEFAULT_LOCAL_DOCKER_HOST); + assert_eq!(endpoint.tls, None); } #[test] @@ -394,4 +525,65 @@ mod tests { assert!(error.to_string().contains("Docker context 'missing'")); } + + #[test] + fn resolves_tcp_context_without_tls_material_as_plain_host() { + let temp = TempDir::new().unwrap(); + write_json( + &temp.path().join("contexts/meta/hash/meta.json"), + r#"{"Name":"remote","Endpoints":{"docker":{"Host":"tcp://docker.example:2375","SkipTLSVerify":false}}}"#, + ); + + let endpoint = resolve_docker_endpoint(Some(temp.path()), None, Some("remote")).unwrap(); + + assert_eq!(endpoint.host, "tcp://docker.example:2375"); + assert_eq!(endpoint.tls, None); + } + + #[test] + fn resolves_https_context_with_tls_material() { + let temp = TempDir::new().unwrap(); + write_json( + &temp.path().join("contexts/meta/hash/meta.json"), + r#"{"Name":"secure","Endpoints":{"docker":{"Host":"https://docker.example:2376","SkipTLSVerify":false}}}"#, + ); + write_json( + &temp.path().join("contexts/tls/hash/docker/ca.pem"), + "ca", + ); + write_json( + &temp.path().join("contexts/tls/hash/docker/cert.pem"), + "cert", + ); + write_json( + &temp.path().join("contexts/tls/hash/docker/key.pem"), + "key", + ); + + let endpoint = resolve_docker_endpoint(Some(temp.path()), None, Some("secure")).unwrap(); + + assert_eq!(endpoint.host, "https://docker.example:2376"); + assert_eq!( + endpoint.tls, + Some(DockerTlsMaterial { + ca_path: temp.path().join("contexts/tls/hash/docker/ca.pem"), + cert_path: temp.path().join("contexts/tls/hash/docker/cert.pem"), + key_path: temp.path().join("contexts/tls/hash/docker/key.pem"), + }) + ); + } + + #[test] + fn ssh_context_is_rejected_explicitly() { + let temp = TempDir::new().unwrap(); + write_json( + &temp.path().join("contexts/meta/hash/meta.json"), + r#"{"Name":"ssh-ctx","Endpoints":{"docker":{"Host":"ssh://docker.example","SkipTLSVerify":false}}}"#, + ); + + let endpoint = resolve_docker_endpoint(Some(temp.path()), None, Some("ssh-ctx")).unwrap(); + let error = connect_to_endpoint(&endpoint).unwrap_err(); + + assert!(error.to_string().contains("SSH Docker contexts are out of scope")); + } }