Skip to content

feat: add host port mapping support for container-to-host communication #830

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 1 commit into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions testcontainers/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@ reusable-containers = []

[dev-dependencies]
anyhow = "1.0.86"
mockall = "0.13"
pretty_env_logger = "0.5"
reqwest = { version = "0.12.4", features = [
"blocking",
Expand Down
110 changes: 110 additions & 0 deletions testcontainers/examples/host_port_exposure_demo.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
use testcontainers::{
core::{ExecCommand, WaitFor},
runners::AsyncRunner,
GenericImage, ImageExt,
};
use tokio::net::TcpListener;

/// This example demonstrates host port exposure functionality with actual connectivity testing.
///
/// Run with RUST_LOG=trace to see detailed platform detection and host mapping logs:
/// RUST_LOG=trace cargo run --example host_port_exposure_demo
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
// Initialize logging
pretty_env_logger::init();

println!("🔍 Testing host port exposure with actual host service...");

// Start a simple TCP server on the host to demonstrate connectivity
let listener = TcpListener::bind("0.0.0.0:0").await?;
let host_port = listener.local_addr()?.port();
println!("🚀 Started demo service on host port {}", host_port);

// Accept connections in background
let listener_handle = tokio::spawn(async move {
while let Ok((mut stream, addr)) = listener.accept().await {
println!("📞 Host service: received connection from {}", addr);
use tokio::io::AsyncWriteExt;
let response =
"HTTP/1.1 200 OK\r\nContent-Length: 25\r\n\r\nHello from host service!\n";
let _ = stream.write_all(response.as_bytes()).await;
let _ = stream.flush().await;
}
});

// Create a container that exposes the host port
let container = GenericImage::new("alpine", "latest")
.with_wait_for(WaitFor::seconds(1))
.with_exposed_host_port(host_port)
.with_cmd(["sleep", "30"])
.start()
.await?;

println!("✅ Container started with host port {} exposed!", host_port);
println!(
"📋 The container can access the host service via host.testcontainers.internal:{}",
host_port
);

// Wait for the container to be ready
tokio::time::sleep(std::time::Duration::from_millis(500)).await;

// Check /etc/hosts configuration inside the container
let mut exec_result = container
.exec(ExecCommand::new(["cat", "/etc/hosts"]))
.await?;

let stdout = exec_result.stdout_to_vec().await?;
let hosts_content = String::from_utf8_lossy(&stdout);
println!("\n📝 Container /etc/hosts content:");
for line in hosts_content.lines() {
if line.contains("host.testcontainers.internal") {
println!(" ✓ {}", line);
} else if !line.trim().is_empty() {
println!(" {}", line);
}
}

// Verify the mapping exists
if hosts_content.contains("host.testcontainers.internal") {
println!("✅ host.testcontainers.internal is configured in /etc/hosts");
} else {
println!("❌ host.testcontainers.internal not found in /etc/hosts");
container.stop().await?;
listener_handle.abort();
return Ok(());
}

// Test connectivity from container to host service
println!("\n🔗 Testing connectivity from container to host service...");
let mut exec_result = container
.exec(ExecCommand::new([
"sh",
"-c",
&format!(
"echo 'GET / HTTP/1.1\\r\\nHost: host.testcontainers.internal\\r\\n\\r\\n' | nc host.testcontainers.internal {} && echo 'Connection successful' || echo 'Connection failed'",
host_port
)
]))
.await?;

let stdout = exec_result.stdout_to_vec().await?;
let output = String::from_utf8_lossy(&stdout);

if output.contains("Hello from host service!") {
println!("✅ Successfully connected to host service from container!");
println!("📄 Response received: \"Hello from host service!\"");
} else if output.contains("Connection failed") {
println!("❌ Failed to connect to host service");
} else {
println!("⚠️ Unexpected output: {}", output.trim());
}

println!("\n🛑 Stopping container...");
container.stop().await?;
listener_handle.abort();

println!("✨ Demo completed!");
Ok(())
}
1 change: 1 addition & 0 deletions testcontainers/src/core.rs
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ pub(crate) mod copy;
pub(crate) mod env;
pub mod error;
pub(crate) mod healthcheck;
pub(crate) mod host_port_mapping;
pub mod logs;
pub(crate) mod mounts;
pub(crate) mod network;
Expand Down
7 changes: 7 additions & 0 deletions testcontainers/src/core/client.rs
Original file line number Diff line number Diff line change
Expand Up @@ -481,6 +481,13 @@ impl Client {
.map_err(ClientError::RemoveNetwork)
}

pub(crate) async fn docker_version(&self) -> Result<Option<String>, ClientError> {
match self.bollard.version().await {
Ok(version) => Ok(version.version),
Err(err) => Err(ClientError::Init(err)),
}
}

pub(crate) async fn docker_hostname(&self) -> Result<url::Host, ClientError> {
let docker_host = &self.config.docker_host();
let docker_host_url = Url::from_str(docker_host)
Expand Down
9 changes: 9 additions & 0 deletions testcontainers/src/core/containers/request.rs
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ pub struct ContainerRequest<I: Image> {
pub(crate) labels: BTreeMap<String, String>,
pub(crate) env_vars: BTreeMap<String, String>,
pub(crate) hosts: BTreeMap<String, Host>,
pub(crate) exposed_host_ports: Vec<u16>,
pub(crate) mounts: Vec<Mount>,
pub(crate) copy_to_sources: Vec<CopyToContainer>,
pub(crate) ports: Option<Vec<PortMapping>>,
Expand Down Expand Up @@ -63,6 +64,8 @@ pub enum Host {
Addr(IpAddr),
#[display("host-gateway")]
HostGateway,
#[display("{0}")]
Hostname(String),
}

#[derive(Debug, Clone, Copy)]
Expand Down Expand Up @@ -106,6 +109,10 @@ impl<I: Image> ContainerRequest<I> {
self.hosts.iter().map(|(name, host)| (name.into(), host))
}

pub fn exposed_host_ports(&self) -> &[u16] {
&self.exposed_host_ports
}

pub fn mounts(&self) -> impl Iterator<Item = &Mount> {
self.image.mounts().into_iter().chain(self.mounts.iter())
}
Expand Down Expand Up @@ -231,6 +238,7 @@ impl<I: Image> From<I> for ContainerRequest<I> {
labels: BTreeMap::default(),
env_vars: BTreeMap::default(),
hosts: BTreeMap::default(),
exposed_host_ports: Vec::new(),
mounts: Vec::new(),
copy_to_sources: Vec::new(),
ports: None,
Expand Down Expand Up @@ -285,6 +293,7 @@ impl<I: Image + Debug> Debug for ContainerRequest<I> {
.field("labels", &self.labels)
.field("env_vars", &self.env_vars)
.field("hosts", &self.hosts)
.field("exposed_host_ports", &self.exposed_host_ports)
.field("mounts", &self.mounts)
.field("ports", &self.ports)
.field("ulimits", &self.ulimits)
Expand Down
3 changes: 3 additions & 0 deletions testcontainers/src/core/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,9 @@ pub enum TestcontainersError {
Exec(#[from] ExecError),
#[error("I/O error: {0}")]
Io(#[from] std::io::Error),
/// Represents an error when host port mapping is not available
#[error("host port mapping unavailable")]
HostPortMappingUnavailable,
/// Represents any other error that does not fit into the above categories
#[error("other error: {0}")]
Other(Box<dyn Error + Sync + Send>),
Expand Down
169 changes: 169 additions & 0 deletions testcontainers/src/core/host_port_mapping.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,169 @@
use std::collections::BTreeMap;

use crate::core::{client::Client, containers::request::Host, error::TestcontainersError};

/// Checks if we're running on Docker Desktop (macOS or Windows).
/// Docker Desktop provides `host.docker.internal` by default.
fn is_docker_desktop() -> bool {
let is_desktop = cfg!(any(target_os = "macos", target_os = "windows"));
Copy link
Contributor

@DDtKey DDtKey Aug 10, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This won’t actually work as is because we can’t rely solely on the operating system of the user for that.

Testcontainers support remote Docker hosts.

if is_desktop {
log::trace!("Platform: Docker Desktop, host.docker.internal available");
} else {
log::trace!("Platform: not Docker Desktop");
}
is_desktop
}

/// Checks if a Docker version string is at least the specified major.minor version
fn is_docker_version_at_least(version_str: &str, min_major: u32, min_minor: u32) -> bool {
// Docker version strings can be like "20.10.17", "24.0.0", etc.
let parts: Vec<&str> = version_str.split('.').collect();
if parts.len() < 2 {
return false;
}

let major: u32 = parts[0].parse().unwrap_or(0);
let minor: u32 = parts[1].parse().unwrap_or(0);
Comment on lines +20 to +26
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let's rely on https://crates.io/crates/semver for that?


major > min_major || (major == min_major && minor >= min_minor)
}

/// Checks if the current Docker installation supports the `host-gateway` feature.
/// This is available in Docker 20.10+ on Linux.
async fn supports_host_gateway(client: &Client) -> bool {
if !cfg!(target_os = "linux") {
log::trace!("Platform: not Linux, host-gateway unsupported");
return false;
}

log::trace!("Platform: Linux, checking Docker version for host-gateway");

// Check Docker version to see if it supports host-gateway
match client.docker_version().await {
Ok(Some(version_str)) => {
let supported = is_docker_version_at_least(&version_str, 20, 10);
if !supported {
log::warn!("Docker version {} detected on Linux - host-gateway may not be supported (requires 20.10+)", version_str);
}
supported
}
Ok(None) => {
// No version info available, assume it's supported but warn
log::warn!(
"Docker version not available - assuming host-gateway is supported on Linux"
);
true
}
Err(err) => {
// If we can't get version info, assume it's supported but warn
log::warn!(
"Failed to detect Docker version ({}), assuming host-gateway is supported on Linux",
err
);
true
}
}
Comment on lines +33 to +65
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This looks like good candidate to be a method of client as main argument is &Client

But in order to split the logic and decouple from the Client I'd suggest to define some trait and impl it for the Client (e.g impl HostMapping for Client { ...})

}

/// Determines the appropriate native method for exposing host ports based on the Docker environment.
///
/// Returns the hostname that should be used for `host.testcontainers.internal` mapping.
/// - On Docker Desktop (macOS/Windows): uses `host.docker.internal`
/// - On Linux with Docker 20.10+: uses `host-gateway`
/// - Returns `None` if no native support is available
pub async fn detect_native_host_mapping(client: &Client) -> Option<Host> {
log::trace!("Detecting native host mapping method");

// Try host-gateway first (modern Linux Docker)
if supports_host_gateway(client).await {
log::info!("Host mapping: using host-gateway");
Some(Host::HostGateway)
}
// Try host.docker.internal (Docker Desktop)
else if is_docker_desktop() {
log::info!("Host mapping: using host.docker.internal");
Some(Host::Hostname("host.docker.internal".to_string()))
}
// No native support available
else {
log::warn!("Host mapping: none available, fallback needed");
None
}
}

/// Adds the host.testcontainers.internal mapping to the hosts map if host ports are exposed.
///
/// This function tries native Docker support (host-gateway on Linux, host.docker.internal on Docker Desktop).
/// If native support is not available, returns an error.
pub async fn setup_host_mapping(
client: &Client,
hosts: &mut BTreeMap<String, Host>,
exposed_host_ports: &[u16],
) -> Result<(), TestcontainersError> {
if exposed_host_ports.is_empty() {
log::info!("No host ports exposed, skipping host mapping setup");
return Ok(());
}

log::trace!(
"Setting up host mapping for ports: {:?}",
exposed_host_ports
);

// First try native Docker support
if let Some(host_mapping) = detect_native_host_mapping(client).await {
log::trace!("Adding host.testcontainers.internal -> {}", host_mapping);
hosts.insert("host.testcontainers.internal".to_string(), host_mapping);
return Ok(());
}

// If native mapping fails, return an error
Err(TestcontainersError::HostPortMappingUnavailable)
}

#[cfg(test)]
mod tests {
use std::collections::BTreeMap;

use super::*;

#[test]
fn test_version_parsing() {
// Test supported versions
assert!(is_docker_version_at_least("20.10.17", 20, 10));
assert!(is_docker_version_at_least("24.0.0", 20, 10));
assert!(is_docker_version_at_least("21.0.0", 20, 10));

// Test unsupported versions
assert!(!is_docker_version_at_least("19.03.12", 20, 10));
assert!(!is_docker_version_at_least("20.9.0", 20, 10));
assert!(!is_docker_version_at_least("invalid", 20, 10));

// Edge cases for version parsing
assert!(!is_docker_version_at_least("", 20, 10));
assert!(!is_docker_version_at_least("20", 20, 10));
assert!(!is_docker_version_at_least("abc.def", 20, 10));
assert!(is_docker_version_at_least("20.10", 20, 10));
assert!(is_docker_version_at_least("20.10.0.extra.stuff", 20, 10));
}

#[test]
fn test_setup_host_mapping_no_ports() {
// This test can remain synchronous since it doesn't actually call the async methods
let rt = tokio::runtime::Runtime::new().unwrap();
rt.block_on(async {
use crate::core::client::Client;

let client = Client::lazy_client().await.unwrap();
let mut hosts = BTreeMap::new();
let ports = vec![];

setup_host_mapping(&client, &mut hosts, &ports)
.await
.unwrap();

// Should never add host mapping when no ports are exposed
assert!(!hosts.contains_key("host.testcontainers.internal"));
});
}
}
21 changes: 21 additions & 0 deletions testcontainers/src/core/image/image_ext.rs
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,21 @@ pub trait ImageExt<I: Image> {
/// Adds a host to the container.
fn with_host(self, key: impl Into<String>, value: impl Into<Host>) -> ContainerRequest<I>;

/// Exposes a host port to the container.
///
/// The exposed port will be accessible from within the container at `host.testcontainers.internal:<port>`.
/// This allows containers to access services running on the host machine.
///
/// # Examples
/// ```rust,no_run
/// use testcontainers::{GenericImage, ImageExt};
///
/// let image = GenericImage::new("alpine", "latest")
/// .with_exposed_host_port(8080)
/// .with_exposed_host_port(5432);
/// ```
fn with_exposed_host_port(self, port: u16) -> ContainerRequest<I>;

/// Adds a mount to the container.
fn with_mount(self, mount: impl Into<Mount>) -> ContainerRequest<I>;

Expand Down Expand Up @@ -288,6 +303,12 @@ impl<RI: Into<ContainerRequest<I>>, I: Image> ImageExt<I> for RI {
container_req
}

fn with_exposed_host_port(self, port: u16) -> ContainerRequest<I> {
let mut container_req = self.into();
container_req.exposed_host_ports.push(port);
container_req
}

fn with_mount(self, mount: impl Into<Mount>) -> ContainerRequest<I> {
let mut container_req = self.into();
container_req.mounts.push(mount.into());
Expand Down
Loading