Skip to main content

lab_ops_natmap/
docker.rs

1//! Docker client helpers for discovering and inspecting container port mappings.
2
3use std::net::IpAddr;
4use std::net::SocketAddr;
5use std::str::FromStr;
6
7use bollard::Docker;
8use color_eyre::Result;
9
10use crate::models::DockerPortMap;
11use crate::models::DockerPortMapRequest;
12
13/// Connects to the local Docker daemon via its default Unix socket.
14pub fn connect() -> Result<Docker> {
15    lab_ops_lab_lib::docker::connect()
16}
17
18/// Discovers all published port mappings for a container.
19///
20/// Inspects the container's network settings and parses its exposed ports
21/// into [`DockerPortMap`] entries. Handles both IPv4 and IPv6 host
22/// bindings when the host IP is unspecified (`0.0.0.0`).
23pub async fn get_port_mappings(docker: &Docker, c_id: &str) -> Result<Vec<DockerPortMap>> {
24    let inspect = docker.inspect_container(c_id, None).await?;
25    let c_name = inspect
26        .name
27        .as_deref()
28        .map(lab_ops_lab_lib::docker::trim_container_name)
29        .unwrap_or("unknown")
30        .to_string();
31
32    let Some(network_settings) = inspect.network_settings else {
33        return Ok(vec![]);
34    };
35
36    // Find the primary container IP address. We check networks attached.
37    let Some(c_ip) = network_settings.networks.as_ref().and_then(|networks| {
38        networks.values().find_map(|net| {
39            net.ip_address
40                .as_ref()
41                .filter(|ip| !ip.is_empty())
42                .and_then(|ip| IpAddr::from_str(ip).ok())
43        })
44    }) else {
45        tracing::debug!(container.id = %c_id, "container has no IP address, skipping ports");
46        return Ok(vec![]);
47    };
48
49    let Some(ports) = network_settings.ports else {
50        return Ok(vec![]);
51    };
52
53    let mut mappings = vec![];
54    for (port_proto, bindings) in ports {
55        let Some(bindings) = bindings else { continue };
56
57        // Parse container port and proto, e.g., "80/tcp"
58        let parts: Vec<&str> = port_proto.split('/').collect();
59        if parts.len() != 2 {
60            // this shouldn't happen
61            continue;
62        }
63
64        let Ok(c_port) = u16::from_str(parts[0]) else {
65            continue;
66        };
67
68        let Ok(proto) = parts[1].to_lowercase().parse() else {
69            continue;
70        };
71
72        let container_addr = SocketAddr::new(c_ip, c_port);
73
74        for bind in bindings {
75            let Some(host_port) = bind
76                .host_port
77                .as_deref()
78                // Ignore ranges for now or parse the first port in range
79                .and_then(|s| s.split('-').next())
80                .and_then(|s| s.parse::<u16>().ok())
81            else {
82                continue;
83            };
84
85            let host_ip_str = bind.host_ip.as_deref().unwrap_or_default();
86            let ips: &[&str] = if host_ip_str.is_empty() || host_ip_str == "0.0.0.0" {
87                &["0.0.0.0", "::"]
88            } else {
89                &[host_ip_str]
90            };
91            mappings.extend(ips.iter().filter_map(|ip| {
92                let host_ip = IpAddr::from_str(ip).ok()?;
93                let req = DockerPortMapRequest {
94                    host_addr: SocketAddr::new(host_ip, host_port),
95                    container_addr,
96                    proto,
97                };
98                Some(DockerPortMap::new(0, req, c_id.to_string(), c_name.clone()))
99            }));
100        }
101    }
102
103    Ok(mappings)
104}